180 lines
7.7 KiB
Python
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),
|
|
}
|