125 lines
5.4 KiB
Python
125 lines
5.4 KiB
Python
"""
|
|
Accounts Summary — the cross-month view of APPROVED journal entries.
|
|
|
|
One row per (month, marketplace): the journal's GL lines (accrual presentation — Transfer
|
|
excluded, Receivable = net revenue Dr A/R) in local currency plus the session's FX rate, so
|
|
the UI can show any marketplace across every month, or all marketplaces converted to USD.
|
|
|
|
Only months whose journal has been APPROVED appear — approval on the Journal Entry tab is
|
|
the publish step. A month drops out again if its sign-off is withdrawn, if it is
|
|
re-processed (the sign-off clears with the numbers), or if a month-end control blocks it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.orm import Session as OrmSession
|
|
|
|
from ...core.journal import ACCRUAL_DISPLAY_EXCLUDES, LINE_ACCOUNTS, RECEIVABLE_KEY
|
|
from ...db import models
|
|
from ..deps import db_dep, is_blocked
|
|
|
|
router = APIRouter(prefix="/api", tags=["accounts-summary"])
|
|
|
|
|
|
@router.get("/accounts-summary")
|
|
def accounts_summary(db: OrmSession = Depends(db_dep)) -> dict:
|
|
from .ar import _journal_for, _market_list, fx_for
|
|
|
|
entries = (
|
|
db.query(models.JournalEntry, models.Session)
|
|
.join(models.Session, models.Session.id == models.JournalEntry.session_id)
|
|
.filter(models.JournalEntry.approved_by != "")
|
|
.order_by(models.Session.reporting_month, models.Session.id)
|
|
.all()
|
|
)
|
|
|
|
line_keys = [k for k, _ in LINE_ACCOUNTS if k not in ACCRUAL_DISPLAY_EXCLUDES]
|
|
months: list[dict] = []
|
|
cells: list[dict] = []
|
|
marketplaces: set[str] = set()
|
|
|
|
for j, s in entries:
|
|
if is_blocked(s) or not j.data:
|
|
continue # a blocked closing publishes nothing
|
|
payload = json.loads(j.data)
|
|
month = s.reporting_month or (s.month_end_date.isoformat()[:7]
|
|
if s.month_end_date else f"session-{s.id}")
|
|
months.append({
|
|
"month": month,
|
|
"session_id": s.id,
|
|
"session_name": s.name,
|
|
"reviewed_by": j.reviewed_by or "",
|
|
"approved_by": j.approved_by or "",
|
|
"approved_at": j.approved_at.isoformat() if j.approved_at else None,
|
|
"entry_no": j.entry_no or "",
|
|
})
|
|
for mkt in _market_list(payload):
|
|
sub, _ = _journal_for(payload, mkt)
|
|
rate, currency = fx_for(db, s.id, mkt)
|
|
values = {ln["key"]: ln["total"] for ln in sub.get("lines", [])
|
|
if ln["key"] not in ACCRUAL_DISPLAY_EXCLUDES}
|
|
accrual = sub.get("receivable_accrual")
|
|
if accrual is None:
|
|
# Payload stored before the accrual figure existed: derive it.
|
|
accrual_total = -round(sum(values.values()), 2)
|
|
else:
|
|
accrual_total = accrual["total"]
|
|
marketplaces.add(mkt)
|
|
cells.append({
|
|
"month": month,
|
|
"session_id": s.id,
|
|
"marketplace": mkt,
|
|
"currency": currency,
|
|
"fx_rate": rate,
|
|
"values": values, # line key -> local-currency total
|
|
"receivable": accrual_total, # Dr A/R (net revenue accrued)
|
|
})
|
|
|
|
# Months that HAVE results but are not published, so they don't just vanish from this
|
|
# view without a word (the #1 "my previous month disappeared" confusion): processed or
|
|
# blocked closings whose journal is not approved — including sign-offs cleared by a
|
|
# re-process — are listed with the reason and a link target.
|
|
published_ids = {m["session_id"] for m in months}
|
|
pending: list[dict] = []
|
|
candidates = db.query(models.Session).filter(
|
|
models.Session.status.in_(("processed", "blocked", "completed"))).all()
|
|
journals = {j.session_id: j for j in db.query(models.JournalEntry).filter(
|
|
models.JournalEntry.session_id.in_([s.id for s in candidates]))} if candidates else {}
|
|
for s in candidates:
|
|
if s.id in published_ids:
|
|
continue
|
|
j = journals.get(s.id)
|
|
if is_blocked(s):
|
|
reason = f"blocked by a failed month-end control — {s.blocked_reason}"
|
|
elif j is None or not j.data:
|
|
reason = "no journal entry yet — re-process the closing"
|
|
elif j.approved_by:
|
|
reason = "approved, but the closing is blocked or has no journal data"
|
|
elif j.entry_no and not j.reviewed_by:
|
|
# An entry number exists but both sign-offs are empty: the usual cause is a
|
|
# re-process, which deliberately withdraws review/approval.
|
|
reason = ("sign-off was cleared (typically by re-processing) — "
|
|
"review and approve the journal again to re-publish")
|
|
else:
|
|
reason = "journal not approved yet — approval is what publishes a month here"
|
|
pending.append({
|
|
"month": s.reporting_month or (s.month_end_date.isoformat()[:7]
|
|
if s.month_end_date else f"session-{s.id}"),
|
|
"session_id": s.id,
|
|
"session_name": s.name,
|
|
"reason": reason,
|
|
})
|
|
pending.sort(key=lambda p: p["month"])
|
|
|
|
return {
|
|
"available": bool(months),
|
|
"line_keys": line_keys,
|
|
"receivable_key": RECEIVABLE_KEY,
|
|
"months": months,
|
|
"marketplaces": sorted(marketplaces),
|
|
"cells": cells,
|
|
"pending": pending,
|
|
}
|