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.

On-shift decision flow for off-hours dispatch A decision ladder: a work order ready to dispatch has the current UTC time converted to each technician's local timezone with zoneinfo; a decision checks whether the technician is on shift now, meaning within their shift window and not on a PTO date. If yes, the router assigns the on-shift technician directly. If no, the router checks the on-call roster for that timezone and hour; if an on-call technician is available it assigns them instead; if not, it defers the work order to the next shift start, computing the earliest upcoming shift start in UTC and logging the deferral rather than assigning an off-shift or unavailable technician. Refuse off-shift dispatch: on-shift decision flow Work order ready to dispatch requested_completion stored in UTC Convert now (UTC) to local time via zoneinfo(tz_name) — never compare naive clocks On shift now? within shift window AND not a PTO date yes no Assign to on-shift technician mode = on_shift Off shift — check on-call roster for this technician's timezone right now On-call technician available? on_call flag set, not on PTO yes no Assign to on-call technician mode = on_call Defer to next shift start compute next shift_start in UTC; log mode = deferred

Root Cause Analysis

Three gaps stacked to produce the 02:14 assignment, and any one of them alone would have been survivable.

  1. The availability check compared naive clocks. datetime.now().time() on the dispatch server was compared directly against a shift_start/shift_end pair 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.
  2. 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.
  3. 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.

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.