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
```python
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
```python
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)
```python
# 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
```python
# 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):**
```python
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
```python
# 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
```python
# 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)
```python
# 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)
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```json
// 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
```python
# 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
```python
# 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)
```python
# 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
```python
# 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
```python
# 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
```python
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
```python
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
```python
# 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)
```sql
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
```python
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
```python
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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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)
```python
# 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
```python
# 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
```sql
-- 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.