Blocking After-Hours Dispatch to Off-Shift Technicians
A dispatch router that auto-assigns work orders inside shift calendar integration for dispatch is only as safe as its availability check — and a check that ignores timezones will happily wake a technician who is off-shift, on PTO, or three states away at 2 AM. This is the failure mode behind one recurring incident inside technician routing and SLA enforcement: the router compared a work order’s arrival time against a technician’s shift window using naive local times, found no on-call rotation to fall back to, and assigned the job anyway.
Incident Profile
A critical work order landed in the queue at 02:14 local server time. The router had already matched a qualified technician through skill-based dispatch certification matching, so it proceeded straight to availability and assigned the job without ever confirming the technician was awake, on shift, or even in the country.
2026-07-15 02:14:07,118 INFO [dispatch_router] Auto-assign triggered for WO-55210 (priority=critical)
2026-07-15 02:14:07,119 DEBUG [dispatch_router] Candidate technician pool: ['T-118', 'T-204', 'T-331']
2026-07-15 02:14:07,120 DEBUG [dispatch_router] Availability check: comparing system local time 02:14:07 against shift_start=06:00, shift_end=14:00 (naive, no timezone)
2026-07-15 02:14:07,121 WARNING [dispatch_router] No timezone context resolved for technician T-204 shift record — treating shift window as system-local
2026-07-15 02:14:07,205 INFO [dispatch_router] Assigned WO-55210 to technician T-204 (shift record: 06:00-14:00 America/Denver)
2026-07-15 02:14:07,206 INFO [notification_service] SMS delivered to T-204: "New critical work order assigned - respond within 15 min"
2026-07-15 02:31:44,890 WARNING [sla_timer] WO-55210 response SLA breach risk: no technician acknowledgement after 17 min
2026-07-15 06:52:12,004 INFO [audit_log] Technician T-204 complaint logged: "Woken up at 2am for a work order — I'm on PTO until Wednesday and three states away"
Two details give the bug away. First, the availability check logs a comparison of 02:14:07 against a 06:00-14:00 window and still returns a match — that only happens if both sides of the comparison were treated as the same naive clock instead of two different timezones. Second, the technician was on PTO, which the router never checked at all; the shift-window comparison was the only gate, and it passed by accident.
Root Cause Analysis
Three gaps stacked to produce the 02:14 assignment, and any one of them alone would have been survivable.
- The availability check compared naive clocks.
datetime.now().time()on the dispatch server was compared directly against ashift_start/shift_endpair stored on the technician’s record with no timezone attached. When the server and the technician sit in different zones — or the server runs in UTC while shift hours are entered in local wall-clock time — the comparison is meaningless; it happened to pass here purely by coincidence of clock values. - PTO was never part of the gate. The technician’s leave record existed in the CMMS, but the router’s availability check only ever asked “is now inside the shift window,” never “is this technician actually working today.” A shift window with no PTO check is a schedule, not an availability check.
- No on-call roster existed outside business hours. Even with a correct timezone-aware check, a critical work order arriving at 2 AM needs someone to route to. The router had no concept of an on-call rotation — it treated “everyone is off shift” as equivalent to “assign the nearest match anyway” instead of falling back to a dedicated on-call technician or deferring the job.
The SLA implications make this worse than a scheduling annoyance: the SLA timer escalation clock started the moment the work order was created, regardless of business hours, so the false assignment burned real response-time budget while the actual technician slept through the notification.
Resolution
The fix has two parts: make every shift comparison timezone-aware using the standard library’s zoneinfo, and add a fallback ladder — on-shift, then on-call, then defer — so the router never has to force an assignment onto someone who isn’t available.
Before — naive comparison, no PTO check, no fallback:
from datetime import datetime, time
def is_on_shift(shift_start: time, shift_end: time) -> bool:
# BUG: uses the dispatch server's naive local time, with no
# timezone attached to either side of the comparison.
now = datetime.now().time()
return shift_start <= now <= shift_end
def auto_assign(work_order_id: str, technicians: list) -> str:
for tech in technicians:
# tech["shift_start"] / tech["shift_end"] are the technician's
# LOCAL shift hours, compared against the SERVER's local clock —
# two different timezones treated as one.
if is_on_shift(tech["shift_start"], tech["shift_end"]):
return tech["id"]
# No on-call fallback and no PTO check: the loop falls through
# and the caller assigns the first technician in the pool anyway.
return technicians[0]["id"]
After — timezone-aware shift windows with an on-call fallback:
from dataclasses import dataclass, field
from datetime import datetime, time, timedelta, timezone
from typing import List, Optional
from zoneinfo import ZoneInfo
@dataclass
class Shift:
"""A technician's recurring shift window, defined in their own local timezone."""
technician_id: str
tz_name: str
shift_start: time
shift_end: time
on_call: bool = False
pto_dates: List[str] = field(default_factory=list) # ISO dates, e.g. "2026-07-15"
def is_on_shift_now(self, at_utc: datetime) -> bool:
local_dt = at_utc.astimezone(ZoneInfo(self.tz_name))
if local_dt.date().isoformat() in self.pto_dates:
return False
local_time = local_dt.time()
if self.shift_start <= self.shift_end:
return self.shift_start <= local_time < self.shift_end
# Overnight shift wraps past midnight, e.g. 22:00-06:00.
return local_time >= self.shift_start or local_time < self.shift_end
def find_assignable_technician(shifts: List[Shift], at_utc: datetime) -> Optional[Shift]:
"""First technician who is genuinely on shift right now, in their own timezone."""
for shift in shifts:
if not shift.on_call and shift.is_on_shift_now(at_utc):
return shift
return None
def find_on_call_technician(shifts: List[Shift], at_utc: datetime) -> Optional[Shift]:
"""Fall back to whoever is flagged on-call and not on PTO."""
for shift in shifts:
if shift.on_call and shift.is_on_shift_now(at_utc):
return shift
return None
Every comparison now happens inside is_on_shift_now, and it always converts the shared UTC instant into the technician’s own timezone before checking the window — the server’s local clock never enters the calculation. PTO is checked in the same method as the shift window so it can never be silently skipped, and a dedicated on_call flag lets the roster distinguish “working their normal shift” from “the person to call when nobody else is.”
Minimal Reproducible Pipeline
This script runs as-is. It defines the canonical WorkOrderPayload (with the site-wide SLA fields priority, requested_completion, and escalation_tier), the tz-aware Shift model, and an assign() function that refuses to dispatch to an off-shift technician — falling back to on-call, and finally to a logged deferral, rather than forcing a match.
import logging
from dataclasses import dataclass, field
from datetime import datetime, time, timedelta, timezone
from enum import Enum
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("shift_dispatch_router")
class Priority(str, Enum):
CRITICAL = "critical"
HIGH = "high"
STANDARD = "standard"
PLANNED = "planned"
@dataclass
class WorkOrderPayload:
"""Canonical CMMS work order — SLA fields are mandatory site-wide."""
work_order_id: str
asset_id: str
part_skus: List[str]
required_quantities: Dict[str, int]
location_id: str
priority: Priority = Priority.STANDARD
requested_completion: Optional[datetime] = None
escalation_tier: int = 0
status: str = "open"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class Shift:
"""A technician's recurring shift window, defined in their own local timezone."""
technician_id: str
tz_name: str
shift_start: time
shift_end: time
on_call: bool = False
pto_dates: List[str] = field(default_factory=list)
def is_on_shift_now(self, at_utc: datetime) -> bool:
local_dt = at_utc.astimezone(ZoneInfo(self.tz_name))
if local_dt.date().isoformat() in self.pto_dates:
return False
local_time = local_dt.time()
if self.shift_start <= self.shift_end:
return self.shift_start <= local_time < self.shift_end
return local_time >= self.shift_start or local_time < self.shift_end
def find_assignable_technician(shifts: List[Shift], at_utc: datetime) -> Optional[Shift]:
for shift in shifts:
if not shift.on_call and shift.is_on_shift_now(at_utc):
return shift
return None
def find_on_call_technician(shifts: List[Shift], at_utc: datetime) -> Optional[Shift]:
for shift in shifts:
if shift.on_call and shift.is_on_shift_now(at_utc):
return shift
return None
def next_shift_start_utc(shifts: List[Shift], at_utc: datetime) -> Optional[datetime]:
"""Earliest upcoming shift_start across all technicians, converted to UTC."""
candidates = []
for shift in shifts:
local_tz = ZoneInfo(shift.tz_name)
local_now = at_utc.astimezone(local_tz)
candidate_local = local_now.replace(
hour=shift.shift_start.hour,
minute=shift.shift_start.minute,
second=0,
microsecond=0,
)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
candidates.append(candidate_local.astimezone(timezone.utc))
return min(candidates) if candidates else None
def assign(wo: WorkOrderPayload, shifts: List[Shift], at_utc: Optional[datetime] = None) -> Dict[str, object]:
"""Assign a work order to a technician who is verifiably on shift, on-call, or defer."""
at_utc = at_utc or datetime.now(timezone.utc)
technician = find_assignable_technician(shifts, at_utc)
if technician is not None:
logger.info("Assigned %s to %s (on_shift)", wo.work_order_id, technician.technician_id)
return {"work_order_id": wo.work_order_id, "assigned_to": technician.technician_id, "mode": "on_shift"}
on_call = find_on_call_technician(shifts, at_utc)
if on_call is not None:
logger.info("Assigned %s to %s (on_call)", wo.work_order_id, on_call.technician_id)
return {"work_order_id": wo.work_order_id, "assigned_to": on_call.technician_id, "mode": "on_call"}
deferred_until = next_shift_start_utc(shifts, at_utc)
logger.warning("Deferred %s — no on-shift or on-call technician until %s", wo.work_order_id, deferred_until)
return {
"work_order_id": wo.work_order_id,
"assigned_to": None,
"mode": "deferred",
"deferred_until": deferred_until.isoformat() if deferred_until else None,
}
if __name__ == "__main__":
# Reproduces WO-55210: incident timestamp is 02:14 America/Denver (08:14 UTC).
incident_time = datetime(2026, 7, 15, 8, 14, 0, tzinfo=timezone.utc)
roster = [
Shift("T-118", "America/Denver", time(6, 0), time(14, 0)),
Shift("T-204", "America/Denver", time(6, 0), time(14, 0), pto_dates=["2026-07-15", "2026-07-16"]),
Shift("T-331", "America/Denver", time(22, 0), time(6, 0), on_call=True),
]
work_order = WorkOrderPayload(
work_order_id="WO-55210",
asset_id="CHILLER-07",
part_skus=["VLV-2201"],
required_quantities={"VLV-2201": 1},
location_id="SITE-04",
priority=Priority.CRITICAL,
escalation_tier=1,
)
result = assign(work_order, roster, at_utc=incident_time)
print(result)
Running the script assigns WO-55210 to T-331 with mode: "on_call" — T-118 is off shift at that hour, T-204 is correctly excluded because 2026-07-15 is a PTO date on their record, and the overnight on-call technician picks up the job instead of a naive comparison waking someone on leave.
Prevention Checklist
FAQ
Why not just store every shift in UTC and avoid timezones altogether?
Shift hours are set by people describing their own local schedule — “6 AM to 2 PM” means their local 6 AM, not a UTC offset that shifts under daylight saving. Storing the IANA zone name alongside the shift and converting at comparison time keeps the human-entered schedule correct and lets zoneinfo handle daylight saving transitions automatically.
What if the on-call rotation spans multiple timezones?
Give each on-call entry its own tz_name and treat the rotation like any other shift: is_on_shift_now already evaluates each technician against their own zone, so a regional on-call handoff at 22:00 Eastern and 19:00 Pacific works without special-casing. Coordinate the handoff with geographic zone routing so the on-call technician assigned is also one who can reasonably reach the site.
Does this replace the geographic zone routing check?
No — this check answers “is anyone available right now,” and zone routing answers “who among the available technicians is close enough to dispatch.” Run the shift check first to build the candidate pool, then apply zone or drive-time filtering on top of whoever passes it.
How does zoneinfo handle daylight saving time changes in a shift window?
Because is_on_shift_now converts the shared UTC instant into the technician’s zone at comparison time, zoneinfo applies whatever UTC offset is correct for that specific date — including the one day a year the offset shifts. A shift stored as “06:00–14:00 America/Denver” stays correct across the spring and fall transitions without any extra code.
Related
Pair this on-shift check with the certification logic in matching technician certifications to work order trades, route a fallback on-call assignment through zone-partitioned dispatch so it doesn’t cross an unreasonable drive time, keep the response clock work aligned with pausing SLA clocks for parts-on-order holds, and record every deferred or on-call assignment through exporting ISO 55001 audit trails for regulators.
Part of: Shift Calendar Integration for Dispatch.