313 lines
15 KiB
Python
313 lines
15 KiB
Python
"""Read endpoints for the dashboard: summary, settlements, transactions, exceptions, etc."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session as OrmSession
|
|
|
|
from ...core.receivable import AGING_BANDS, classify_aging
|
|
from ...core.settlements import RECEIVABLE_ACCOUNT_TYPES
|
|
from ...db import models
|
|
from ..auth import actor_name
|
|
from ..deps import (blocked_payload, db_dep, ensure_editable, get_session_or_404, is_blocked,
|
|
to_dict)
|
|
|
|
router = APIRouter(prefix="/api/sessions", tags=["results"])
|
|
|
|
# Amazon closes a settlement roughly every two weeks; a receivable is not past due until that
|
|
# cycle plus the clearing lag has elapsed. Used only by the aging bands.
|
|
SETTLEMENT_CYCLE_DAYS = 14
|
|
|
|
|
|
@router.get("/{session_id}/summary")
|
|
def summary(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|
s = get_session_or_404(session_id, db)
|
|
# Headline receivable: withheld entirely while a month-end control is failing.
|
|
if is_blocked(s):
|
|
return blocked_payload(s)
|
|
setts = db.query(models.Settlement).filter(models.Settlement.session_id == session_id).all()
|
|
recon = db.query(models.ReconciliationRow).filter(
|
|
models.ReconciliationRow.session_id == session_id).first()
|
|
rec_rows = db.query(models.ReceivableResultRow).filter(
|
|
models.ReceivableResultRow.session_id == session_id,
|
|
models.ReceivableResultRow.account_type == "TOTAL").all()
|
|
exc = db.query(models.Exception_.severity, func.count()).filter(
|
|
models.Exception_.session_id == session_id).group_by(models.Exception_.severity).all()
|
|
n_txn = db.query(func.count(models.Transaction.id)).filter(
|
|
models.Transaction.session_id == session_id).scalar() or 0
|
|
n_recv_txn = db.query(func.count(models.Transaction.id)).filter(
|
|
models.Transaction.session_id == session_id,
|
|
models.Transaction.receivable_flag == True).scalar() or 0 # noqa: E712
|
|
|
|
receivable_settlements = [s for s in setts if s.status == "receivable"]
|
|
return {
|
|
"closing_receivable_usd": recon.final_receivable_usd if recon else None,
|
|
# Self-consistency of the bucketing only — see core/reconciliation.py. The month-end
|
|
# controls (/controls) are what tells you whether the close can be trusted.
|
|
"bucket_identity_status": recon.status if recon else None,
|
|
"reconciliation_status": recon.status if recon else None, # deprecated alias
|
|
"reserve_total": recon.reserve_total if recon else 0.0,
|
|
"transfers_total": recon.transfers_total if recon else 0.0,
|
|
"receivable_orders": recon.receivable_orders if recon else 0.0,
|
|
"paid_orders": recon.paid_orders if recon else 0.0,
|
|
"num_settlements": len(setts),
|
|
"num_receivable_settlements": len(receivable_settlements),
|
|
"num_paid_settlements": len(setts) - len(receivable_settlements),
|
|
"num_transactions": n_txn,
|
|
"num_receivable_transactions": n_recv_txn,
|
|
"exceptions_by_severity": {sev: c for sev, c in exc},
|
|
"receivable_by_marketplace": [
|
|
{"marketplace": r.marketplace, "receivable_local": round(r.receivable_local),
|
|
"receivable_usd": r.receivable_usd, "currency": r.currency} for r in rec_rows
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/{session_id}/receivable")
|
|
def receivable(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
|
|
get_session_or_404(session_id, db)
|
|
rows = db.query(models.ReceivableResultRow).filter(
|
|
models.ReceivableResultRow.session_id == session_id).all()
|
|
return [to_dict(r, ["marketplace", "account_type", "additional_sales", "reserve",
|
|
"receivable_local", "fx_rate", "receivable_usd", "currency"]) for r in rows]
|
|
|
|
|
|
@router.get("/{session_id}/settlements")
|
|
def settlements(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
|
|
get_session_or_404(session_id, db)
|
|
rows = db.query(models.Settlement).filter(
|
|
models.Settlement.session_id == session_id).order_by(
|
|
models.Settlement.marketplace, models.Settlement.account_type,
|
|
models.Settlement.settlement_id).all()
|
|
return [to_dict(r, ["marketplace", "account_type", "settlement_id", "order_total",
|
|
"transfer_total", "transfer_amount", "transfer_date", "transfer_received",
|
|
"row_count", "first_date", "last_date", "status"]) for r in rows]
|
|
|
|
|
|
@router.get("/{session_id}/transactions")
|
|
def transactions(session_id: int, db: OrmSession = Depends(db_dep),
|
|
limit: int = Query(100, le=1000), offset: int = 0,
|
|
settlement_id: str | None = None, txn_type: str | None = None,
|
|
marketplace: str | None = None, receivable: bool | None = None,
|
|
storage: bool | None = None, search: str | None = None) -> dict:
|
|
get_session_or_404(session_id, db)
|
|
q = db.query(models.Transaction).filter(models.Transaction.session_id == session_id)
|
|
if settlement_id:
|
|
q = q.filter(models.Transaction.settlement_id == settlement_id)
|
|
if txn_type:
|
|
# match either the original (possibly localized) type or the canonical English one
|
|
q = q.filter((models.Transaction.txn_type == txn_type)
|
|
| (models.Transaction.txn_type_en == txn_type))
|
|
if marketplace:
|
|
q = q.filter(models.Transaction.marketplace == marketplace)
|
|
if receivable is not None:
|
|
q = q.filter(models.Transaction.receivable_flag == receivable)
|
|
if storage is not None:
|
|
q = q.filter(models.Transaction.storage_flag == storage)
|
|
if search:
|
|
like = f"%{search}%"
|
|
q = q.filter((models.Transaction.order_id.like(like)) | (models.Transaction.sku.like(like)))
|
|
total = q.count()
|
|
rows = q.order_by(models.Transaction.id).offset(offset).limit(limit).all()
|
|
return {
|
|
"total": total, "limit": limit, "offset": offset,
|
|
"rows": [to_dict(r, ["id", "source_file", "source_row", "marketplace", "settlement_id",
|
|
"order_id", "sku", "txn_type", "txn_type_en", "account_type",
|
|
"posted_date", "total", "currency", "settlement_status",
|
|
"receivable_flag", "storage_flag"])
|
|
for r in rows],
|
|
}
|
|
|
|
|
|
@router.get("/{session_id}/exceptions")
|
|
def exceptions(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]:
|
|
get_session_or_404(session_id, db)
|
|
rows = db.query(models.Exception_).filter(
|
|
models.Exception_.session_id == session_id).all()
|
|
return [to_dict(r, ["category", "severity", "detail", "source"]) for r in rows]
|
|
|
|
|
|
@router.get("/{session_id}/reconciliation")
|
|
def reconciliation(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|
get_session_or_404(session_id, db)
|
|
r = db.query(models.ReconciliationRow).filter(
|
|
models.ReconciliationRow.session_id == session_id).first()
|
|
if not r:
|
|
return {}
|
|
d = to_dict(r, ["uploaded_total", "receivable_orders", "paid_orders", "transfers_total",
|
|
"reserve_total", "manual_adjustments", "final_receivable_usd",
|
|
"identity_difference", "status"])
|
|
try:
|
|
d["notes"] = json.loads(r.notes) if r.notes else []
|
|
except Exception:
|
|
d["notes"] = []
|
|
return d
|
|
|
|
|
|
@router.get("/{session_id}/journal")
|
|
def journal(session_id: int, marketplace: str | None = None,
|
|
db: OrmSession = Depends(db_dep)) -> dict:
|
|
"""One marketplace's journal (default: the primary one), plus the sign-off state.
|
|
|
|
The sign-off (review → approval) is per CLOSING, not per marketplace — approving
|
|
publishes every marketplace's journal for the month to the Accounts Summary."""
|
|
from .ar import _journal_for, _market_list
|
|
get_session_or_404(session_id, db)
|
|
j = db.query(models.JournalEntry).filter(
|
|
models.JournalEntry.session_id == session_id).first()
|
|
if not j or not j.data:
|
|
return {"available": False}
|
|
payload = json.loads(j.data)
|
|
markets = _market_list(payload)
|
|
data, mkt = _journal_for(payload, marketplace)
|
|
data = dict(data) # never mutate the stored payload
|
|
data["available"] = True
|
|
data["marketplace"] = mkt
|
|
data["marketplaces"] = markets
|
|
data["entry_no"] = j.entry_no or ""
|
|
data["reviewed_by"] = j.reviewed_by or ""
|
|
data["reviewed_at"] = j.reviewed_at.isoformat() if j.reviewed_at else None
|
|
data["approved_by"] = j.approved_by or ""
|
|
data["approved_at"] = j.approved_at.isoformat() if j.approved_at else None
|
|
return data
|
|
|
|
|
|
@router.put("/{session_id}/journal/entry-no")
|
|
def set_journal_entry_no(session_id: int, entry_no: str = Body(..., embed=True),
|
|
db: OrmSession = Depends(db_dep)) -> dict:
|
|
ensure_editable(get_session_or_404(session_id, db))
|
|
j = db.query(models.JournalEntry).filter(
|
|
models.JournalEntry.session_id == session_id).first()
|
|
if j:
|
|
j.entry_no = entry_no
|
|
db.commit()
|
|
return {"entry_no": entry_no}
|
|
|
|
|
|
def _journal_row_or_400(session_id: int, db: OrmSession) -> models.JournalEntry:
|
|
j = db.query(models.JournalEntry).filter(
|
|
models.JournalEntry.session_id == session_id).first()
|
|
if j is None or not j.data:
|
|
raise HTTPException(400, "Process the closing before signing off its journal entry.")
|
|
return j
|
|
|
|
|
|
@router.post("/{session_id}/journal/review")
|
|
def review_journal(session_id: int, request: Request, name: str = Body("", embed=True),
|
|
db: OrmSession = Depends(db_dep)) -> dict:
|
|
"""Step 1 of the sign-off: a person confirms they reviewed this month's journal.
|
|
|
|
The signed-in user's display name is recorded; the body `name` only counts when no
|
|
one is signed in (auth off — dev and tests)."""
|
|
ensure_editable(get_session_or_404(session_id, db))
|
|
who = actor_name(request, name)
|
|
if not who:
|
|
raise HTTPException(400, "A reviewer name is required.")
|
|
j = _journal_row_or_400(session_id, db)
|
|
j.reviewed_by = who
|
|
j.reviewed_at = dt.datetime.utcnow()
|
|
db.commit()
|
|
return journal(session_id, None, db)
|
|
|
|
|
|
@router.post("/{session_id}/journal/approve")
|
|
def approve_journal(session_id: int, request: Request, name: str = Body("", embed=True),
|
|
db: OrmSession = Depends(db_dep)) -> dict:
|
|
"""Step 2: approval — this is what publishes the month to the Accounts Summary.
|
|
|
|
Requires a prior review, and a closing that isn't blocked by a month-end control:
|
|
an unverified number must never become part of the cross-month accounts view.
|
|
The signed-in user's display name is recorded (body `name` only without auth)."""
|
|
s = get_session_or_404(session_id, db)
|
|
ensure_editable(s)
|
|
who = actor_name(request, name)
|
|
if not who:
|
|
raise HTTPException(400, "An approver name is required.")
|
|
if is_blocked(s):
|
|
raise HTTPException(409, f"This closing is blocked by a failed month-end control — "
|
|
f"{s.blocked_reason}")
|
|
j = _journal_row_or_400(session_id, db)
|
|
if not j.reviewed_by:
|
|
raise HTTPException(400, "The journal must be reviewed before it can be approved.")
|
|
j.approved_by = who
|
|
j.approved_at = dt.datetime.utcnow()
|
|
db.commit()
|
|
return journal(session_id, None, db)
|
|
|
|
|
|
@router.post("/{session_id}/journal/reset-signoff")
|
|
def reset_journal_signoff(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|
"""Withdraw the sign-off (removes the month from the Accounts Summary)."""
|
|
ensure_editable(get_session_or_404(session_id, db))
|
|
j = _journal_row_or_400(session_id, db)
|
|
j.reviewed_by = ""
|
|
j.reviewed_at = None
|
|
j.approved_by = ""
|
|
j.approved_at = None
|
|
db.commit()
|
|
return journal(session_id, None, db)
|
|
|
|
|
|
@router.get("/{session_id}/aging")
|
|
def aging(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
|
|
"""
|
|
Real aging, banded by days **past due** — not days since the transaction.
|
|
|
|
An Amazon receivable is not due when the order posts; it is due when its settlement
|
|
disburses. Amazon closes a settlement roughly every 14 days and the payout then takes the
|
|
clearing lag to land, so a settlement is only overdue once
|
|
`last activity + SETTLEMENT_CYCLE_DAYS + clearing_lag` has passed.
|
|
|
|
A healthy month therefore lands ~100% in Current, matching the Finance workbook. What
|
|
changes is that a settlement Amazon is actually holding — dispute, account review, stuck
|
|
disbursement — now ages into 1-30/31-60/61-90 instead of hiding inside Current, which is
|
|
the entire point of an aging report. (Banding by transaction date instead would push a
|
|
normal biweekly settlement into 1-30 and make the report meaningless.)
|
|
"""
|
|
s = get_session_or_404(session_id, db)
|
|
if is_blocked(s):
|
|
return blocked_payload(s)
|
|
rows = db.query(models.ReceivableResultRow).filter(
|
|
models.ReceivableResultRow.session_id == session_id,
|
|
models.ReceivableResultRow.account_type == "TOTAL").all()
|
|
setts = db.query(models.Settlement).filter(
|
|
models.Settlement.session_id == session_id,
|
|
models.Settlement.status == "receivable").all()
|
|
month_end = s.month_end_date
|
|
|
|
# Local-currency band composition from the settlements that make up the receivable.
|
|
by_mkt: dict[str, dict[str, float]] = {}
|
|
lag = s.clearing_lag_days or 0
|
|
for st in setts:
|
|
if (st.account_type or "").strip().lower() not in RECEIVABLE_ACCOUNT_TYPES:
|
|
continue # transfers / unspecified aren't receivable
|
|
if month_end and st.last_date:
|
|
due = st.last_date + dt.timedelta(days=SETTLEMENT_CYCLE_DAYS + lag)
|
|
days_overdue = (month_end - due).days
|
|
else:
|
|
days_overdue = 0
|
|
band = classify_aging(days_overdue)
|
|
by_mkt.setdefault(st.marketplace, {b: 0.0 for b in AGING_BANDS})[band] += st.order_total
|
|
|
|
matrix = []
|
|
for r in rows:
|
|
local_bands = by_mkt.get(r.marketplace) or {b: 0.0 for b in AGING_BANDS}
|
|
composed = sum(local_bands.values())
|
|
# The receivable is ROUND(reserve + additional sales); the reserve and that rounding
|
|
# belong to the current period, so the residual lands in Current and the row still
|
|
# ties exactly to the headline receivable.
|
|
residual = (r.receivable_local or 0.0) - composed
|
|
rate = r.fx_rate or 1.0
|
|
band_usd = {b: round(v * rate, 2) for b, v in local_bands.items()}
|
|
band_usd["Current"] = round((local_bands["Current"] + residual) * rate, 2)
|
|
total = round(sum(band_usd.values()), 2)
|
|
matrix.append({"marketplace": r.marketplace, "currency": r.currency,
|
|
**band_usd, "Total": total})
|
|
return {"bands": list(AGING_BANDS), "rows": matrix,
|
|
"basis": (f"days past due at month-end — a settlement becomes due "
|
|
f"{SETTLEMENT_CYCLE_DAYS} days after its last activity plus the "
|
|
f"{lag}-day clearing lag")}
|