Spaces:
Sleeping
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:
- Sales order WITH coordinates β Can verify (but human decides)
- Sales order WITHOUT coordinates β Assignment arrival becomes source of truth
- 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
- Assignments = People + Actions (not just ticket state)
- History Preserved (new row per reassignment)
- Team Support (multiple active assignments)
- GPS Proof (journey trail + arrival)
- Expense Basis (assignment links to expenses)
- Capacity Limits (max 5 tickets per agent)
- 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.