"""Cost-governance finding triage — the 'action' layer over findings. Governance findings are recomputed every read, so a decision (this is fine / deal with it next quarter / fixed) has nowhere to live and the finding re-nags forever. ``finance_finding_acks`` records one triage row per ``(kind, ref)``; this module is the thin service over it: * ``set_ack`` — upsert a triage decision (acknowledged / snoozed / resolved) for one finding, * ``load_ack_map`` — fetch all acks for a kind keyed by ref, so a read can annotate findings in one pass, * ``is_suppressed`` — whether an ack hides a finding from the active list (resolved, or snoozed with a future ``snooze_until``). """ from __future__ import annotations from datetime import datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.domains.finance.models import FinanceFindingAck VALID_STATUSES = {"acknowledged", "snoozed", "resolved"} async def set_ack(db: AsyncSession, *, kind: str, ref: str, status: str, snooze_until: datetime | None = None, note: str | None = None, actor_id: str | None = None) -> FinanceFindingAck: kind = (kind or "").strip() ref = (ref or "").strip() if not kind or not ref: raise AppException("BAD_REQUEST", "kind and ref are required", status_code=422) if status not in VALID_STATUSES: raise AppException( "BAD_STATUS", f"status must be one of {sorted(VALID_STATUSES)}", status_code=422, ) if status == "snoozed" and snooze_until is None: raise AppException( "SNOOZE_UNTIL_REQUIRED", "snoozed status requires snooze_until", status_code=422, ) row = (await db.execute( select(FinanceFindingAck).where( FinanceFindingAck.kind == kind, FinanceFindingAck.ref == ref, ) )).scalar_one_or_none() if row is None: row = FinanceFindingAck(kind=kind, ref=ref, status=status) db.add(row) row.status = status row.snooze_until = snooze_until if status == "snoozed" else None row.note = (note or None) row.actor_person_id = (actor_id or None) await db.flush() return row async def load_ack_map(db: AsyncSession, kind: str) -> dict[str, FinanceFindingAck]: rows = (await db.execute( select(FinanceFindingAck).where(FinanceFindingAck.kind == kind) )).scalars().all() return {r.ref: r for r in rows} def is_suppressed(ack: FinanceFindingAck | None, now: datetime) -> bool: """A finding is hidden from the active list when resolved, or snoozed with a snooze window that hasn't elapsed yet. Acknowledged findings stay visible (just badged) — acknowledgement ≠ hiding. """ if ack is None: return False if ack.status == "resolved": return True if ack.status == "snoozed" and ack.snooze_until is not None: su = ack.snooze_until # Tolerate naive datetimes from a fresh MySQL load (tz stripped). if su.tzinfo is None: su = su.replace(tzinfo=now.tzinfo) return su > now return False def ack_dict(ack: FinanceFindingAck | None) -> dict | None: if ack is None: return None return { "status": ack.status, "snooze_until": ack.snooze_until.isoformat() if ack.snooze_until else None, "note": ack.note, }