Finance-Accounts/ar-aging-app/backend/app/services/controls_run.py

180 lines
7.7 KiB
Python

"""
Run the month-end controls for a session, persist the results, and block the close when a
control fails with error severity.
Runs at the end of processing (after the journal exists) and again on demand, so that
changing an opening balance, an FX rate or a reserve re-evaluates the controls rather than
leaving a stale green light.
"""
from __future__ import annotations
import json
from sqlalchemy.orm import Session as OrmSession
from ..core import controls
from ..core.controls import ControlResult
from ..db import models
def _per_market_rows(db: OrmSession, session_id: int) -> tuple[list[dict], float | None]:
"""Per-marketplace movement rows + the FX-converted group closing (All Markets total)."""
from ..api.routes.analytics import all_markets
try:
data = all_markets(session_id, db)
except Exception: # noqa: BLE001 — a control run must never break the close
return [], None
if not data.get("available"):
return [], None
return data.get("markets", []), data.get("total", {}).get("closing_usd")
def _control_total(db: OrmSession, session_id: int) -> float | None:
from ..api.routes.control import _dashboard_metrics
try:
m = _dashboard_metrics(db, session_id)
except Exception: # noqa: BLE001
return None
return m.get("closing_receivable") if m else None
def evaluate(db: OrmSession, session_id: int, result=None) -> list[ControlResult]:
"""
Evaluate every control. `result` is the in-memory ProcessResult when called straight
after processing; on a re-run the file/aggregate controls fall back to what was persisted.
"""
session = db.get(models.Session, session_id)
out: list[ControlResult] = []
# C1 — source row count (engine metas when fresh, persisted file rows otherwise)
metas = list(result.file_metas) if result and result.file_metas else _stored_metas(db, session_id)
out.append(controls.c1_source_row_count(metas))
# C2 — column completeness (journal GL lines vs the source `total` column)
j = db.query(models.JournalEntry).filter(
models.JournalEntry.session_id == session_id).first()
journal = json.loads(j.data) if j and j.data else {}
recon = db.query(models.ReconciliationRow).filter(
models.ReconciliationRow.session_id == session_id).first()
uploaded = recon.uploaded_total if recon else 0.0
out.append(controls.c2_column_completeness(journal, uploaded))
# C3 — bucket completeness (only measurable from the aggregation pass)
if result and result.aggregation:
out.append(controls.c3_bucket_completeness(result.aggregation))
else:
out.append(_prior(db, session_id, "C3", "Bucket completeness"))
# C4 / C6 — cross-method and cross-surface agreement
rows, all_markets_usd = _per_market_rows(db, session_id)
openings_all_zero = not db.query(models.OpeningBalance).filter(
models.OpeningBalance.session_id == session_id,
models.OpeningBalance.amount != 0).first()
out.append(controls.c4_dual_method(rows, openings_all_zero=openings_all_zero))
out.append(controls.c6_currency_integrity(_control_total(db, session_id), all_markets_usd))
# C5 — FX confirmed for this reporting month
fx_rows = db.query(models.FxRate).filter(models.FxRate.session_id == session_id).all()
markets = [r["marketplace"] for r in rows] or [f.marketplace for f in fx_rows if f.marketplace]
out.append(controls.c5_fx_confirmed(fx_rows, (session.reporting_month if session else "") or "",
markets))
return out
class _Meta:
"""Minimal stand-in for FileMeta when re-running controls without re-parsing."""
def __init__(self, f: models.SessionFile):
self.filename = f.filename
self.header_row = 0
self.sheet_last_row = f.sheet_last_row or 0
self.imported_rows = f.imported_rows or 0
self.helper_rows_skipped = f.helper_rows_skipped or 0
self.blank_rows_skipped = f.blank_rows_skipped or 0
@property
def expected_data_rows(self) -> int:
return max(0, self.sheet_last_row - self.header_row)
@property
def rows_accounted_for(self) -> int:
return self.imported_rows + self.helper_rows_skipped + self.blank_rows_skipped
def _stored_metas(db: OrmSession, session_id: int) -> list:
rows = db.query(models.SessionFile).filter(
models.SessionFile.session_id == session_id).all()
# header_row isn't persisted; reuse the stored expected count only when it was recorded.
metas = []
for f in rows:
m = _Meta(f)
if m.sheet_last_row:
# Reconstruct the header offset from what was imported, so a stale re-run cannot
# invent a mismatch; a genuine mismatch still shows up on the next full process.
m.header_row = max(0, m.sheet_last_row - m.rows_accounted_for)
metas.append(m)
return metas
def _prior(db: OrmSession, session_id: int, key: str, label: str) -> ControlResult:
"""Carry a previous run's verdict forward when it cannot be recomputed without re-parsing."""
row = db.query(models.ControlResult).filter(
models.ControlResult.session_id == session_id,
models.ControlResult.key == key).first()
if row is None:
return ControlResult(key=key, label=label, status=controls.NA, severity="info",
detail="Not evaluated on this run (requires re-processing).")
try:
evidence = json.loads(row.evidence) if row.evidence else []
except ValueError:
evidence = []
return ControlResult(key=row.key, label=row.label, status=row.status,
severity=row.severity, detail=row.detail, evidence=evidence)
def run_and_persist(db: OrmSession, session_id: int, result=None) -> dict:
"""Evaluate, store, and apply the block. Returns a JSON-ready summary."""
results = evaluate(db, session_id, result)
db.query(models.ControlResult).filter(
models.ControlResult.session_id == session_id).delete(synchronize_session=False)
for r in results:
db.add(models.ControlResult(
session_id=session_id, key=r.key, label=r.label, status=r.status,
severity=r.severity, detail=r.detail, evidence=json.dumps(r.evidence)))
reason = controls.blocking_summary(results)
session = db.get(models.Session, session_id)
if session is not None:
session.blocked_reason = reason
# Never downgrade a terminal state; only flip between processed and blocked.
if session.status in ("processed", "blocked", "completed"):
session.status = "blocked" if reason else (
"completed" if session.status == "completed" and not reason else "processed")
db.commit()
return payload(db, session_id)
def payload(db: OrmSession, session_id: int) -> dict:
rows = db.query(models.ControlResult).filter(
models.ControlResult.session_id == session_id).order_by(models.ControlResult.key).all()
session = db.get(models.Session, session_id)
out = []
for r in rows:
try:
evidence = json.loads(r.evidence) if r.evidence else []
except ValueError:
evidence = []
out.append({"key": r.key, "label": r.label, "status": r.status,
"severity": r.severity, "detail": r.detail, "evidence": evidence,
"checked_at": r.checked_at.isoformat() if r.checked_at else None})
return {
"available": bool(out),
"controls": out,
"blocked": bool(session and session.blocked_reason),
"blocked_reason": (session.blocked_reason if session else "") or "",
"passed": sum(1 for r in out if r["status"] == "pass"),
"failed": sum(1 for r in out if r["status"] == "fail"),
"total": len(out),
}