import frappe from datetime import date, timedelta _TRIAGE_WINDOW_DEFAULT = 3 # Rule 11.4 default: days from creation before triage is overdue _RESOLUTION_WINDOW_DEFAULT = 5 # Rule 11.4 default: days since last modified before resolution is overdue _LONG_CYCLE_GRACE_DAYS = 5 # Rule 11.4: Process (long cycle) grace window def _long_cycle_milestones(doc): """The up-to-4 milestone dates a 'Long' cycle_type process reports against (BRD 4.4/7.1).""" return [d for d in (doc.long_cycle_date_1, doc.long_cycle_date_2, doc.long_cycle_date_3, doc.long_cycle_date_4) if d] def _current_long_cycle_milestone(doc, today=None): """Most recent Long-cycle milestone date that has already arrived (<= today), or None if none due yet.""" from frappe.utils import getdate today = today or getdate() due = [getdate(d) for d in _long_cycle_milestones(doc) if getdate(d) <= today] return max(due) if due else None def _get_org_incident_windows(): """ Return {org_name: (triage_days, resolution_days)} from SB Organization settings. Falls back to defaults when an org has not configured its own windows (Rule 11.4). """ orgs = frappe.get_all('SB Organization', fields=['name', 'incident_triage_window_days', 'incident_resolution_window_days'], ignore_permissions=True ) return { org.name: ( int(org.incident_triage_window_days or _TRIAGE_WINDOW_DEFAULT), int(org.incident_resolution_window_days or _RESOLUTION_WINDOW_DEFAULT), ) for org in orgs } def check_overdue_incidents(): """ Rule 11.3-3: Incidents still New past per-org triage window → notify Auditors. Rule 11.3-5: Incidents in Pending Manager not updated within per-org resolution window → notify Auditors + Manager. Windows are configurable per-org via SB Organization.incident_triage/resolution_window_days (Rule 11.4). """ today = date.today() windows = _get_org_incident_windows() # ── Triage overdue (Rule 11.3-3) ───────────────────────────────────────── # Use the smallest configured window as the DB pre-filter, then narrow per-org in Python min_triage = min((t for t, _ in windows.values()), default=_TRIAGE_WINDOW_DEFAULT) global_triage_cutoff = str(today - timedelta(days=min_triage)) untriaged = frappe.get_all('SB Incident', filters={'workflow_state': 'New', 'creation': ['<', global_triage_cutoff]}, fields=['name', 'title', 'organization', 'creation'], ignore_permissions=True ) flagged_triage = 0 for inc in untriaged: triage_days, _ = windows.get(inc.organization, (_TRIAGE_WINDOW_DEFAULT, _RESOLUTION_WINDOW_DEFAULT)) org_cutoff = today - timedelta(days=triage_days) inc_date = frappe.utils.getdate(inc.creation) if inc_date < org_cutoff: _notify_by_role( ['SB Auditor', 'SB Admin', 'System Manager'], 'Incident overdue for triage ({} days) — {}'.format(triage_days, inc.title or inc.name), 'SB Incident', inc.name ) flagged_triage += 1 # ── Resolution overdue (Rule 11.3-5) ───────────────────────────────────── min_resolution = min((r for _, r in windows.values()), default=_RESOLUTION_WINDOW_DEFAULT) global_resolution_cutoff = str(today - timedelta(days=min_resolution)) stale = frappe.get_all('SB Incident', filters={ 'workflow_state': ['in', ['Accept & Under Progress', 'working on resolution']], 'modified': ['<', global_resolution_cutoff], }, fields=['name', 'title', 'organization', 'assigned_to', 'modified'], ignore_permissions=True ) flagged_resolution = 0 for inc in stale: _, resolution_days = windows.get(inc.organization, (_TRIAGE_WINDOW_DEFAULT, _RESOLUTION_WINDOW_DEFAULT)) org_cutoff = today - timedelta(days=resolution_days) modified_date = frappe.utils.getdate(inc.modified) if modified_date < org_cutoff: if inc.assigned_to: _send_notification_task( inc.assigned_to, 'Incident resolution overdue — please update your status: {}'.format(inc.title or inc.name), 'SB Incident', inc.name ) _notify_by_role( ['SB Auditor', 'SB Admin', 'System Manager'], 'Incident resolution overdue — Manager has not updated in {} days'.format(resolution_days), 'SB Incident', inc.name ) flagged_resolution += 1 frappe.db.commit() return flagged_triage + flagged_resolution def run_cycle_engine(): """ Daily cycle engine — evaluates all Active Cycles whose grace window has expired. Grace window = cycle.end_date + cycle.update_window_days (Rule 11.4). For each Process/Order with no actor update in this cycle → auto-log Unknown + escalation. """ from frappe.utils import getdate today = date.today() today_str = str(today) # All cycles whose end_date has passed (still Active) all_past = frappe.get_all('SB Cycle', filters={'status': 'Active', 'end_date': ['<', today_str]}, fields=['name', 'organization', 'end_date', 'update_window_days'], ignore_permissions=True ) # Filter to only those past the grace period (Rule 11.4) closed_cycles = [ c for c in all_past if today > getdate(c.end_date) + timedelta(days=int(c.update_window_days or 2)) ] # ── Process cycle processing ────────────────────────────────────────────── # 'Long' cycle_type processes are excluded here — they don't follow the org's # regular Cycle grid, they're evaluated against their own milestone dates below # (Rule 11.4: Process long cycle = 5-day grace instead of the Cycle's window). for cycle in closed_cycles: processes = frappe.get_all('SB Process', filters={'cycle': cycle.name, 'cycle_type': ['!=', 'Long']}, fields=['name', 'actor', 'organization', 'workflow_state'], ignore_permissions=True ) for proc in processes: doc = frappe.get_doc('SB Process', proc.name) # Skip if actor already submitted an update for this cycle if any(r.cycle == cycle.name for r in (doc.cycle_logs or [])): continue # Auto-log Unknown (Rule 11.1-3) log_row = doc.append('cycle_logs', {}) log_row.cycle = cycle.name log_row.cycle_date = today_str log_row.status = 'Unknown' log_row.actor = proc.actor or '' doc.status = 'Unknown' _apply_process_escalation(doc) doc.save(ignore_permissions=True) if proc.actor: _send_notification_task( proc.actor, 'Cycle marked Unknown — please update your process status', 'SB Process', proc.name ) # Mark cycle Closed after processing frappe.db.set_value('SB Cycle', cycle.name, 'status', 'Closed') # ── Order cycle processing ──────────────────────────────────────────────── for cycle in closed_cycles: orders = frappe.get_all('SB Order', filters={'cycle': cycle.name, 'workflow_state': ['not in', ['Closed']]}, fields=['name', 'assigned_to', 'organization'], ignore_permissions=True ) for order in orders: doc = frappe.get_doc('SB Order', order.name) if any(r.cycle == cycle.name for r in (doc.order_cycle_log or [])): continue # Auto-log Unknown (Rule 11.2-3) log_row = doc.append('order_cycle_log', {}) log_row.cycle = cycle.name log_row.cycle_date = today_str log_row.stage = doc.stage or 1 log_row.status = 'Unknown' log_row.actor = order.assigned_to or '' _apply_order_escalation(doc) doc.save(ignore_permissions=True) if order.assigned_to: _send_notification_task( order.assigned_to, 'Cycle marked Unknown — please update your order status', 'SB Order', order.name ) # ── Long-cycle process processing (Rule 11.4: independent of the org Cycle grid) ── _run_long_cycle_processes(today) frappe.db.commit() def _run_long_cycle_processes(today): """ BRD 4.4/7.1 — a 'Long' cycle_type process is not evaluated on the org's regular Cycle grid. It's only due at its own up-to-4 milestone dates, each with its own 5-day grace window (Rule 11.4), independent of the Cycle's update_window_days. """ from frappe.utils import getdate procs = frappe.get_all('SB Process', filters={'cycle_type': 'Long'}, fields=['name', 'actor', 'organization', 'long_cycle_date_1', 'long_cycle_date_2', 'long_cycle_date_3', 'long_cycle_date_4'], ignore_permissions=True ) grace = timedelta(days=_LONG_CYCLE_GRACE_DAYS) for p in procs: milestones = [d for d in (p.long_cycle_date_1, p.long_cycle_date_2, p.long_cycle_date_3, p.long_cycle_date_4) if d] due = [m for m in milestones if today > getdate(m) + grace] if not due: continue doc = frappe.get_doc('SB Process', p.name) logged = {str(r.cycle_date) for r in (doc.cycle_logs or [])} changed = False for m in due: m_str = str(getdate(m)) if m_str in logged: continue log_row = doc.append('cycle_logs', {}) log_row.cycle = doc.cycle or '' log_row.cycle_date = m_str log_row.status = 'Unknown' log_row.actor = p.actor or '' changed = True if not changed: continue doc.status = 'Unknown' _apply_process_escalation(doc) doc.save(ignore_permissions=True) if p.actor: _send_notification_task( p.actor, 'Long-cycle milestone passed — please update your process status', 'SB Process', p.name ) # ── Escalation helpers ──────────────────────────────────────────────────────── def _sorted_logs(logs): """Cycle-log rows in true chronological order. Rows are normally appended in order, but manual form edits can insert/reorder rows — escalation streaks must follow cycle_date, falling back to row position for same-day entries.""" return sorted(logs, key=lambda r: (str(r.cycle_date or ''), r.idx or 0)) def recompute_process_escalation(doc, method=None): """doc_events before_save hook — cycle-history rows can be added or edited directly on the Process form (not only via the workflow/engine), and escalation must always reflect the logs actually stored.""" if (doc.workflow_state or '') == 'Closed': return # Close Process deliberately clears escalation — don't resurrect it _apply_process_escalation(doc) def recompute_order_escalation(doc, method=None): """doc_events before_save hook — same reasoning as recompute_process_escalation.""" if (doc.workflow_state or '') == 'Closed': return _apply_order_escalation(doc) def _trailing_streaks(logs, bad): """Return (consecutive_bad, consecutive_good) counted from the most recent log entry backward — whichever class the last entry belongs to, the other is always 0.""" consecutive_bad = 0 for r in reversed(logs): if r.status in bad: consecutive_bad += 1 else: break consecutive_good = 0 for r in reversed(logs): if r.status not in bad: consecutive_good += 1 else: break return consecutive_bad, consecutive_good def _apply_process_escalation(doc): """ Rule 11.1-4: 3 consecutive (Fail+Unknown) OR 5 total → Level 2. Rule 11.1-2: Any Fail/Unknown → Level 1. Rule 11.1-5: Consecutive Pass resets escalation — a sustained recovery streak (3 in a row, mirroring the escalate threshold) always clears the level, even if the doc has older bad entries further back in its history. """ logs = _sorted_logs(doc.cycle_logs or []) bad = {'Fail', 'Unknown'} total_bad = sum(1 for r in logs if r.status in bad) consecutive_bad, consecutive_good = _trailing_streaks(logs, bad) if consecutive_good >= 3: doc.escalation_level = 0 return if consecutive_bad >= 3 or total_bad >= 5: if (doc.escalation_level or 0) < 2: # only notify on first escalation _notify_owners_task(doc.organization, 'SB Process', doc.name, 'Process') doc.escalation_level = 2 elif total_bad > 0: doc.escalation_level = 1 else: doc.escalation_level = 0 def _apply_order_escalation(doc): """ Rule 11.2-4: 2 consecutive (Deviate+Unknown) OR 3 total → Level 2. Rule 11.2-2: Any Deviate/Unknown → Level 1. Rule 11.2-5: Consecutive Agree resets escalation — a sustained recovery streak (2 in a row, mirroring the escalate threshold) always clears the level, even if the doc has older bad entries further back in its history. """ logs = _sorted_logs(doc.order_cycle_log or []) bad = {'Deviate', 'Unknown'} total_bad = sum(1 for r in logs if r.status in bad) consecutive_bad, consecutive_good = _trailing_streaks(logs, bad) if consecutive_good >= 2: doc.escalation_level = 0 return if consecutive_bad >= 2 or total_bad >= 3: if (doc.escalation_level or 0) < 2: _notify_owners_task(doc.organization, 'SB Order', doc.name, 'Order') doc.escalation_level = 2 elif total_bad > 0: doc.escalation_level = 1 else: doc.escalation_level = 0 # ── Notification helpers ────────────────────────────────────────────────────── def _send_notification_task(user, subject, doctype, docname): try: frappe.get_doc({ 'doctype': 'Notification Log', 'subject': subject, 'for_user': user, 'type': 'Alert', 'document_type': doctype, 'document_name': docname, }).insert(ignore_permissions=True) except Exception: pass def _notify_by_role(roles, subject, doctype, docname): users = frappe.get_all('Has Role', filters={'role': ['in', roles]}, fields=['parent'], ignore_permissions=True ) seen = set() for u in users: if u.parent not in seen: _send_notification_task(u.parent, subject, doctype, docname) seen.add(u.parent) def _notify_owners_task(organization, doctype, docname, label): _notify_by_role( ['SB Owner', 'SB Admin', 'System Manager'], '{} Escalated — Immediate Attention Required'.format(label), doctype, docname )