swiftops-backend / docs /agent /implementation-notes /TICKET_ASSIGNMENTS_COMPREHENSIVE.md
kamau1's picture
feat(project): add complete project setup workflow with service methods and API endpoints for regions, roles, subcontractors, and finalization including validation and authorization
4835b24

Ticket Assignments - Comprehensive Implementation Guide

Overview

Ticket assignments are the heart of the field service management system. They track who is working on what, provide proof of work, enable expense validation, and support both individual and team-based work execution.


Core Concepts

1. Assignment vs Ticket Status

CRITICAL DISTINCTION:

  • Ticket Status: Operational state of work order (open β†’ assigned β†’ in_progress β†’ completed)
  • Assignment: WHO is doing the work and WHAT they did (journey, arrival, expenses)

Key Principle: Assignments track PEOPLE and their ACTIONS. Ticket status tracks WORK state.

2. Assignment History Model

Each row = One work attempt by one agent

Assignment Row = One agent's journey on one day
- Agent A tries Monday (customer not home) β†’ Row 1 (ended_at set)
- Agent B tries Tuesday (completes work) β†’ Row 2 (ended_at set)

NOT an update model - Always INSERT new row for reassignment to preserve history.


Business Rules

Rule 1: Team Size & Assignment Count

Ticket.required_team_size = 3  # How many agents needed (set by dispatcher/PM)
Ticket.assigned_team_size = 0  # Auto-calculated from active assignments

# Auto-calculated
assigned_team_size = count(assignments WHERE ended_at IS NULL)

Purpose: Optimistic locking - prevents "ghost workers"

  • Dispatcher assigns 3 agents β†’ required_team_size = 3
  • System tracks who actually got assigned
  • Prevents over-assignment or invisible assignments

Rule 2: Work Can Start Without Full Team

if assigned_team_size < required_team_size:
    # Work can still start
    # Some agents might be on-site without phones
    # If they didn't claim assignment = their loss (no visibility)

Real-world: Agent shows up, does work, but never accepted assignment in system β†’ No proof of work β†’ No expenses claimable

Rule 3: Multiple Active Assignments (Team Work)

# Same ticket, same day, multiple active assignments = Team working together
Ticket #123:
  - Assignment 1: Agent A (ended_at=NULL) ← Active
  - Assignment 2: Agent B (ended_at=NULL) ← Active  
  - Assignment 3: Agent C (ended_at=NULL) ← Active

Use Case: Infrastructure projects where 3 agents work together on same pole/cable installation.

Rule 4: Reassignment Creates New Row

# Monday: Agent A tries (customer not home)
Assignment 1:
  user_id = Agent A
  assigned_at = Monday 8am
  ended_at = Monday 10am (dropped)
  reason = "Customer not available"

# Tuesday: Agent A reassigned (tries again)
Assignment 2:
  user_id = Agent A  # Same agent!
  assigned_at = Tuesday 8am
  ended_at = NULL (active)
  # New journey, new arrival, new expenses

Why? Preserves daily work record:

  • Monday expenses separate from Tuesday expenses
  • Monday journey separate from Tuesday journey
  • Complete audit trail per work attempt

Rule 5: Assignment Lifecycle States

States (derived from fields, NOT separate column):

def assignment_status(assignment):
    if assignment.ended_at:
        return "CLOSED"  # Dropped/completed/cancelled
    
    if assignment.arrived_at:
        return "ON_SITE"  # Agent at location
    
    if assignment.journey_started_at:
        return "IN_TRANSIT"  # Agent traveling
    
    if assignment.responded_at:
        return "ACCEPTED"  # Agent acknowledged
    
    return "PENDING"  # Awaiting agent response

State Tracking: Use existing timeline fields, no new status column needed.

Rule 6: Ticket Completion Logic

# Team ticket: ANY member can complete
if any_assignment.marks_ticket_complete():
    ticket.status = "completed"
    ticket.completed_at = now()
    # All assignments auto-closed (ended_at set)

Compensation happens outside system - Team decides how to split payment. System only tracks that work was done.

Rule 7: Agent Capacity Limits

# Agent can hold max 5 active tickets
active_assignments = count(assignments WHERE user_id=X AND ended_at IS NULL)
if active_assignments >= 5:
    raise "Agent has reached ticket limit (5)"

Purpose: Prevents ticket hoarding, ensures fair distribution.

Rule 8: Self-Assignment (DISABLED FOR NOW)

# Future feature - agents pick from open ticket queue
# Currently: Only dispatcher/PM can assign
is_self_assigned = False  # Always false for now

Rule 9: Execution Order (Agent Queue Management)

# Agent can reorder their assigned tickets
Assignment.execution_order = 1, 2, 3...  # Agent's planned sequence

# Agent can UPDATE their own execution_order
PUT /assignments/{id}/reorder
{
  "execution_order": 2,
  "planned_start_time": "2024-03-20T10:00:00Z"
}

Assignment Actions (State Transitions)

Action 1: ASSIGNED

# Dispatcher/PM assigns ticket to agent
POST /tickets/{id}/assign
{
  "user_ids": ["agent-a-id", "agent-b-id"],  # Can be multiple (team)
  "execution_order": 1,
  "planned_start_time": "2024-03-20T09:00:00Z"
}

# Creates assignment rows
action = "assigned"
assigned_by_user_id = dispatcher_id
is_self_assigned = False
assigned_at = now()
ended_at = NULL  # Active

Ticket Status: open β†’ assigned

Action 2: ACCEPTED

# Agent accepts assignment
POST /assignments/{id}/accept

# Updates existing row
responded_at = now()
# No new row created

Ticket Status: assigned β†’ assigned (no change until journey starts)

Action 3: REJECTED

# Agent rejects assignment
POST /assignments/{id}/reject
{
  "reason": "Out of service area"
}

# Updates existing row
responded_at = now()
ended_at = now()
reason = "Out of service area"

Ticket Status: assigned β†’ open (back to unassigned)

Assignment closed immediately - No journey happened.

Action 4: START JOURNEY

# Agent starts traveling to site
POST /assignments/{id}/start-journey
{
  "latitude": -1.2921,
  "longitude": 36.8219
}

# Updates existing row
journey_started_at = now()
journey_start_latitude = -1.2921
journey_start_longitude = 36.8219

Ticket Status: assigned β†’ in_progress

GPS tracking begins - Breadcrumb trail captured.

Action 5: ARRIVED

# Agent arrives at site
POST /assignments/{id}/arrived
{
  "latitude": -1.2930,
  "longitude": 36.8225
}

# Updates existing row
arrived_at = now()
arrival_latitude = -1.2930
arrival_longitude = 36.8225
arrival_verified = auto_calculated_if_known_location

Ticket Status: No change (stays in_progress)

Important: Arrival doesn't change ticket status - just tracks agent location.

Action 6: CUSTOMER NOT AVAILABLE

# Agent arrives but customer not home
POST /assignments/{id}/customer-unavailable
{
  "reason": "Customer not at location"
}

# Agent can choose:
# Option A: Drop assignment
ended_at = now()
reason = "Customer not available"

# Option B: Keep assignment (try again later same day)
# No ended_at set, assignment stays active

Ticket Status: in_progress β†’ assigned (back to awaiting execution)

Action 7: DROPPED

# Agent drops ticket (can't complete)
POST /assignments/{id}/drop
{
  "reason": "Equipment shortage / Weather / Emergency"
}

# Updates existing row
ended_at = now()
reason = "Equipment shortage"

Ticket Status: in_progress β†’ assigned (needs reassignment)

Assignment closed - New assignment needed if work still required.

Action 8: COMPLETED

# Agent completes work
POST /assignments/{id}/complete
{
  "work_notes": "Installation successful",
  "location": {"lat": -1.2930, "lng": 36.8225}
}

# Updates existing row
ended_at = now()

# Updates ticket
ticket.status = "completed"
ticket.completed_at = now()

# Close ALL team assignments for this ticket
UPDATE assignments 
SET ended_at = now() 
WHERE ticket_id = X AND ended_at IS NULL

Ticket Status: in_progress β†’ completed

Team Rule: First person to mark complete closes ticket for everyone.


GPS Tracking & Location Verification

Journey Breadcrumb Trail

// journey_location_history JSONB column
[
  {
    "lat": -1.2921,
    "lng": 36.8219,
    "accuracy": 10,
    "timestamp": "2024-01-15T09:30:00Z",
    "speed": 45,
    "battery": 80,
    "network": "4G"
  },
  {
    "lat": -1.2925,
    "lng": 36.8225,
    "accuracy": 8,
    "timestamp": "2024-01-15T09:35:00Z",
    "speed": 50,
    "battery": 78,
    "network": "4G"
  }
]

Updated: Every 1-5 minutes while in transit (journey_started_at β†’ arrived_at)

Arrival Verification

# NO DISTANCE THRESHOLD
# Why? We don't always know customer/task coordinates

arrival_verified = NULL  # Not auto-calculated
# Human verification in admin panel

Use Cases:

  1. Sales order WITH coordinates β†’ Can verify (but human decides)
  2. Sales order WITHOUT coordinates β†’ Assignment arrival becomes source of truth
  3. Tasks β†’ Might not have exact coordinates

Customer Location Discovery

# If sales order lacks coordinates
if not sales_order.has_coordinates():
    # Use first verified assignment's arrival location
    first_assignment = find_first_arrived_assignment(ticket)
    if first_assignment:
        sales_order.update_coordinates(
            lat=first_assignment.arrival_latitude,
            lng=first_assignment.arrival_longitude
        )
        # Future assignments can be verified against this

Team Assignment Scenarios

Scenario A: Infrastructure Team (3 agents)

# Dispatcher assigns infrastructure ticket to team
POST /tickets/123/assign-team
{
  "user_ids": ["agent-a", "agent-b", "agent-c"],
  "required_team_size": 3
}

# Creates 3 assignment rows (all active)
ticket.required_team_size = 3
ticket.assigned_team_size = 3

# Team Lead (Agent A) starts journey for whole team
POST /assignments/A-assignment-id/start-journey

# Agent A arrives (on behalf of team)
POST /assignments/A-assignment-id/arrived

# All agents work together
# Each can log their own expenses (or Agent A logs for all)

# Agent B marks ticket complete
POST /assignments/B-assignment-id/complete

# System auto-closes all 3 assignments
Assignment A: ended_at = now()
Assignment B: ended_at = now()
Assignment C: ended_at = now()

# Ticket completed
ticket.status = "completed"

Scenario B: Solo Agent with Customer Not Home

# Monday: Agent assigned
Assignment 1 created (ended_at=NULL)

# Agent starts journey
journey_started_at = Monday 8am

# Agent arrives
arrived_at = Monday 9am

# Customer not home
POST /assignments/1/customer-unavailable
{
  "action": "drop",
  "reason": "Customer not available"
}

# Assignment closed
ended_at = Monday 9am

# Ticket status back to assigned
ticket.status = "assigned"

# Tuesday: Same agent reassigned (NEW ROW)
Assignment 2 created (ended_at=NULL)
journey_started_at = Tuesday 8am
arrived_at = Tuesday 9am
# Customer home, work completed
ended_at = Tuesday 11am

# Ticket completed
ticket.status = "completed"

Scenario C: Team with Partial Attendance

# Assigned 3 agents
required_team_size = 3
assigned_team_size = 3

# Only 2 agents show up (one has no phone)
# System shows 2 active assignments
# Work proceeds anyway (no system block)

# If 3rd agent never claimed assignment:
# - No proof of work
# - Can't claim expenses
# - Their loss

Expense Validation Integration

Expense Claim Requirements

class TicketExpense:
    ticket_assignment_id: UUID  # MUST link to assignment
    incurred_by_user_id: UUID  # Who spent money
    
    # Validation
    location_verified: Boolean
    verification_notes: Text

Validation Logic

def validate_expense(expense):
    assignment = expense.ticket_assignment
    
    # Check 1: Assignment must have arrived at site
    if not assignment.arrived_at:
        return False, "Agent never arrived at site"
    
    # Check 2: Check ticket_status_history for location verification
    status_change = find_status_change(
        ticket_id=assignment.ticket_id,
        assignment_id=assignment.id,
        communication_method='face_to_face'
    )
    
    if status_change and status_change.location_verified:
        expense.location_verified = True
        return True, "Verified via status change location"
    
    # Check 3: Assignment arrival (no distance check, human verifies)
    if assignment.arrival_verified:
        expense.location_verified = True
        return True, "Verified via assignment arrival"
    
    # Pending human verification
    expense.location_verified = False
    return False, "Pending human verification"

Team Expense Scenarios

# Scenario A: One person claims transport for team
Assignment A (Agent A):
  Expense 1: Transport (KES 1000) - "Taxi for 3 people"
  additional_metadata = {"split_with": ["agent-b-id", "agent-c-id"]}

# Scenario B: Each claims individual expenses
Assignment A (Agent A):
  Expense 1: Transport (KES 500) - "Boda boda"
  
Assignment B (Agent B):
  Expense 2: Transport (KES 500) - "Boda boda"
  
Assignment C (Agent C):
  Expense 3: Meals (KES 300) - "Lunch"

Database Schema Compliance

Existing Fields (NO NEW TABLES NEEDED)

CREATE TABLE ticket_assignments (
    id UUID PRIMARY KEY,
    ticket_id UUID NOT NULL,
    user_id UUID NOT NULL,
    
    -- Assignment Details
    action assignment_action NOT NULL,  -- Use this for state tracking
    assigned_by_user_id UUID,
    is_self_assigned BOOLEAN DEFAULT FALSE,
    
    -- Execution Planning
    execution_order INTEGER,
    planned_start_time TIMESTAMP,
    
    -- Timeline (defines state)
    assigned_at TIMESTAMP,
    responded_at TIMESTAMP,      -- accept/reject
    journey_started_at TIMESTAMP, -- start travel
    arrived_at TIMESTAMP,         -- reached site
    ended_at TIMESTAMP,           -- dropped/completed
    
    -- GPS
    journey_start_latitude DOUBLE PRECISION,
    journey_start_longitude DOUBLE PRECISION,
    arrival_latitude DOUBLE PRECISION,
    arrival_longitude DOUBLE PRECISION,
    arrival_verified BOOLEAN,
    journey_location_history JSONB,
    
    -- Metadata
    reason TEXT,
    notes TEXT,
    
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    deleted_at TIMESTAMP
);

NO NEW COLUMNS NEEDED - Existing schema covers all requirements.

Ticket Model Additions

class Ticket:
    # ... existing fields ...
    
    # NEW: Team size tracking
    required_team_size = Column(Integer, default=1)
    
    # COMPUTED: Auto-calculated from assignments
    @property
    def assigned_team_size(self):
        return count(assignments WHERE ended_at IS NULL)

API Endpoints

1. Assign Ticket (Individual)

POST /api/v1/tickets/{id}/assign
Authorization: PM, Dispatcher, Platform Admin

Body:
{
  "user_id": "agent-uuid",
  "execution_order": 1,
  "planned_start_time": "2024-03-20T09:00:00Z",
  "notes": "Customer prefers morning visit"
}

Response:
{
  "id": "assignment-uuid",
  "ticket_id": "ticket-uuid",
  "user_id": "agent-uuid",
  "action": "assigned",
  "assigned_at": "2024-03-20T08:00:00Z",
  "status": "pending"  // Computed
}

2. Assign Team

POST /api/v1/tickets/{id}/assign-team
Authorization: PM, Dispatcher, Platform Admin

Body:
{
  "user_ids": ["agent-a", "agent-b", "agent-c"],
  "required_team_size": 3,
  "notes": "Infrastructure team for pole installation"
}

Response:
{
  "ticket_id": "ticket-uuid",
  "required_team_size": 3,
  "assigned_team_size": 3,
  "assignments": [
    {"id": "assign-a", "user_id": "agent-a"},
    {"id": "assign-b", "user_id": "agent-b"},
    {"id": "assign-c", "user_id": "agent-c"}
  ]
}

3. Agent Actions

POST /api/v1/assignments/{id}/accept
POST /api/v1/assignments/{id}/reject
Body: { "reason": "..." }

POST /api/v1/assignments/{id}/start-journey
Body: { "latitude": -1.2921, "longitude": 36.8219 }

POST /api/v1/assignments/{id}/update-location
Body: { "latitude": -1.2925, "longitude": 36.8225, "speed": 45, "accuracy": 10 }

POST /api/v1/assignments/{id}/arrived
Body: { "latitude": -1.2930, "longitude": 36.8225 }

POST /api/v1/assignments/{id}/customer-unavailable
Body: { "reason": "...", "action": "drop" | "keep" }

POST /api/v1/assignments/{id}/drop
Body: { "reason": "..." }

POST /api/v1/assignments/{id}/complete
Body: { "work_notes": "...", "location": {...} }

4. Queue Management

GET /api/v1/users/{id}/assignment-queue
Response:
{
  "active_count": 3,
  "max_capacity": 5,
  "assignments": [
    {
      "id": "...",
      "ticket_id": "...",
      "execution_order": 1,
      "planned_start_time": "...",
      "status": "pending",
      "ticket_name": "Customer X - Installation"
    }
  ]
}

PUT /api/v1/assignments/{id}/reorder
Body: { "execution_order": 2, "planned_start_time": "..." }

5. Assignment History

GET /api/v1/tickets/{id}/assignments
Response:
{
  "current_assignments": [...],  // ended_at IS NULL
  "past_assignments": [...]      // ended_at IS NOT NULL
}

Edge Cases & Constraints

Edge Case 1: Agent Capacity Check

def check_agent_capacity(user_id):
    active = count_active_assignments(user_id)
    if active >= 5:
        raise HTTPException(409, "Agent has reached capacity (5 tickets)")

Edge Case 2: Duplicate Team Assignment

# Prevent same agent assigned twice to same ticket
existing = find_assignment(ticket_id=X, user_id=Y, ended_at=NULL)
if existing:
    raise HTTPException(409, "Agent already assigned to this ticket")

Edge Case 3: Reassignment Same Day

# Agent tries same ticket twice in one day
existing_today = find_assignment(
    ticket_id=X, 
    user_id=Y, 
    created_at >= today_start,
    ended_at IS NOT NULL
)

if existing_today:
    # Allow - creates new row
    # Reason: Agent went back after customer became available
    pass

Edge Case 4: Team Completion Race Condition

# Two agents try to complete simultaneously
# Use database transaction + optimistic locking

with db.transaction():
    ticket = db.query(Ticket).with_for_update().get(id)
    
    if ticket.status == "completed":
        raise HTTPException(409, "Ticket already completed")
    
    # Mark complete
    ticket.status = "completed"
    ticket.completed_at = now()
    
    # Close all assignments
    db.query(TicketAssignment).filter(
        ticket_id=X,
        ended_at=NULL
    ).update({"ended_at": now()})
    
    db.commit()

Edge Case 5: Assignment Without Arrival

# Agent marks complete but never marked arrival
if not assignment.arrived_at:
    # Block or warn?
    # DECISION: Warn but allow (trust factor)
    assignment.arrived_at = now()  # Auto-set
    assignment.arrival_verified = False

Edge Case 6: GPS Tracking Failure

# Agent's phone loses GPS signal
# journey_location_history has gaps

# SOLUTION: Accept incomplete trail
# System stores whatever was captured
# Human reviews if suspicious (straight line, teleportation)

Edge Case 7: Assignment Deletion (Soft Delete)

# Never hard delete assignments
deleted_at = now()  # Soft delete only

# Reason: Audit trail required for expenses
# Even rejected/dropped assignments preserved

Performance Metrics

Calculated Metrics

# Travel Time
travel_time = arrived_at - responded_at

# Work Time
work_time = ended_at - arrived_at

# Total Assignment Duration
total_time = ended_at - assigned_at

# Journey Distance (approximate from breadcrumbs)
distance = calculate_path_distance(journey_location_history)

Analytics Queries

-- Agent productivity
SELECT user_id, 
       COUNT(*) as tickets_completed,
       AVG(ended_at - arrived_at) as avg_work_time
FROM ticket_assignments
WHERE ended_at IS NOT NULL AND arrived_at IS NOT NULL
GROUP BY user_id;

-- Ticket completion rate
SELECT ticket_id,
       COUNT(*) as assignment_attempts,
       MAX(CASE WHEN ended_at IS NOT NULL THEN 1 ELSE 0 END) as completed
FROM ticket_assignments
GROUP BY ticket_id;

Summary

Key Takeaways

  1. Assignments = People + Actions (not just ticket state)
  2. History Preserved (new row per reassignment)
  3. Team Support (multiple active assignments)
  4. GPS Proof (journey trail + arrival)
  5. Expense Basis (assignment links to expenses)
  6. Capacity Limits (max 5 tickets per agent)
  7. Human Verification (no auto distance checks)

What Makes This System Powerful

  • Complete Audit Trail: Every work attempt recorded
  • Team Flexibility: Supports solo and team work
  • Expense Validation: GPS + status history prevents fraud
  • Real-World Aligned: Handles customer unavailability, weather, equipment issues
  • Performance Tracking: Travel time, work time, completion rates
  • Location Discovery: Assignments fill missing customer coordinates

This is the foundation for all downstream features: expenses, invoicing, performance analytics, route optimization, and fraud detection.