392 lines
15 KiB
Python
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
|
|
)
|