2026-10-09 12:16:48 +05:30

392 lines
15 KiB
Python

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
)