Spaces:
Sleeping
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 | |
| ```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. | |