# 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.