kamau1 commited on
Commit
95005e1
·
1 Parent(s): d12a170

feat: unified sync notification system, realtime timesheets and payroll, simplified project role compensation

Browse files
Files changed (37) hide show
  1. docs/dev/NOTIFICATION_SYSTEM.md +448 -0
  2. docs/features/REALTIME_TIMESHEET_IMPLEMENTATION.md +714 -0
  3. src/app/api/v1/expenses.py +5 -3
  4. src/app/api/v1/invoice_generation.py +13 -4
  5. src/app/api/v1/payroll.py +287 -8
  6. src/app/api/v1/projects.py +14 -6
  7. src/app/api/v1/sales_orders.py +7 -4
  8. src/app/api/v1/tasks.py +7 -4
  9. src/app/api/v1/ticket_assignments.py +76 -2
  10. src/app/api/v1/tickets.py +7 -3
  11. src/app/models/enums.py +24 -12
  12. src/app/models/project.py +42 -50
  13. src/app/models/ticket.py +5 -1
  14. src/app/models/ticket_assignment.py +4 -0
  15. src/app/models/ticket_expense.py +5 -1
  16. src/app/models/timesheet.py +12 -2
  17. src/app/models/user_payroll.py +27 -29
  18. src/app/schemas/notification.py +4 -0
  19. src/app/schemas/payroll.py +3 -6
  20. src/app/schemas/project.py +55 -44
  21. src/app/services/expense_service.py +133 -59
  22. src/app/services/invoice_generation_service.py +20 -18
  23. src/app/services/notification_creator.py +325 -0
  24. src/app/services/notification_delivery.py +333 -0
  25. src/app/services/notification_helper.py +232 -0
  26. src/app/services/payroll_service.py +367 -55
  27. src/app/services/project_service.py +70 -71
  28. src/app/services/sales_order_service.py +89 -47
  29. src/app/services/task_service.py +89 -47
  30. src/app/services/tende_pay_formatter.py +110 -1
  31. src/app/services/ticket_assignment_service.py +152 -17
  32. src/app/services/ticket_completion_service.py +31 -2
  33. src/app/services/ticket_expense_service.py +191 -65
  34. src/app/services/ticket_service.py +61 -35
  35. src/app/services/timesheet_realtime_service.py +404 -0
  36. supabase/migrations/20241212_realtime_timesheet_updates.sql +162 -0
  37. supabase/migrations/20241212_simplify_compensation_structure.sql +180 -0
docs/dev/NOTIFICATION_SYSTEM.md ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Notification System - Developer Guide
2
+
3
+ ## 🎯 Overview
4
+
5
+ The SwiftOps notification system is a **2-tier architecture** designed for reliability, scalability, and maintainability. It handles all notifications across the platform with a consistent, world-class approach.
6
+
7
+ ### Architecture
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────────────┐
11
+ │ NOTIFICATION SYSTEM │
12
+ ├─────────────────────────────────────────────────────────────┤
13
+ │ │
14
+ │ TIER 1: Notification Creation (Synchronous) │
15
+ │ ┌────────────────────────────────────────────────────┐ │
16
+ │ │ NotificationCreator │ │
17
+ │ │ - Creates notification records in database │ │
18
+ │ │ - Synchronous, transaction-safe │ │
19
+ │ │ - Guaranteed to be saved │ │
20
+ │ │ - Rolls back with parent operation │ │
21
+ │ └────────────────────────────────────────────────────┘ │
22
+ │ ↓ │
23
+ │ TIER 2: Notification Delivery (Asynchronous) │
24
+ │ ┌────────────────────────────────────────────────────┐ │
25
+ │ │ NotificationDelivery │ │
26
+ │ │ - Delivers via external channels (WhatsApp, etc.) │ │
27
+ │ │ - Non-blocking background tasks │ │
28
+ │ │ - Handles failures gracefully │ │
29
+ │ │ - Configurable per channel │ │
30
+ │ └────────────────────────────────────────────────────┘ │
31
+ │ │
32
+ └─────────────────────────────────────────────────────────────┘
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 🚀 Quick Start
38
+
39
+ ### Basic Usage
40
+
41
+ ```python
42
+ from fastapi import BackgroundTasks
43
+ from app.services.notification_creator import NotificationCreator
44
+ from app.services.notification_delivery import NotificationDelivery
45
+
46
+ # In your endpoint
47
+ @router.post("/tickets/assign")
48
+ def assign_ticket(
49
+ background_tasks: BackgroundTasks,
50
+ db: Session = Depends(get_db),
51
+ current_user: User = Depends(get_current_user)
52
+ ):
53
+ # Your business logic
54
+ ticket = create_ticket(...)
55
+ assignment = assign_to_agent(...)
56
+
57
+ # TIER 1: Create notification (synchronous)
58
+ notification = NotificationCreator.create(
59
+ db=db,
60
+ user_id=agent.id,
61
+ title="New Ticket Assigned",
62
+ message=f"You have been assigned to {ticket.ticket_name}",
63
+ source_type="ticket",
64
+ source_id=ticket.id,
65
+ notification_type="assignment",
66
+ channel="whatsapp",
67
+ project_id=ticket.project_id,
68
+ metadata={
69
+ "ticket_number": ticket.ticket_number,
70
+ "priority": "high",
71
+ "action_url": f"/tickets/{ticket.id}"
72
+ }
73
+ )
74
+
75
+ # Commit notification with your business logic
76
+ db.commit()
77
+
78
+ # TIER 2: Queue delivery (asynchronous, non-blocking)
79
+ NotificationDelivery.queue_delivery(
80
+ background_tasks=background_tasks,
81
+ notification_id=notification.id
82
+ )
83
+
84
+ return {"status": "success"}
85
+ ```
86
+
87
+ ---
88
+
89
+ ## 📚 API Reference
90
+
91
+ ### NotificationCreator
92
+
93
+ #### `create()` - Create Single Notification
94
+
95
+ ```python
96
+ notification = NotificationCreator.create(
97
+ db: Session, # Database session
98
+ user_id: UUID, # User to notify
99
+ title: str, # Short title (for display)
100
+ message: str, # Detailed message
101
+ source_type: str, # Entity type (ticket, expense, payroll, etc.)
102
+ source_id: Optional[UUID], # Entity ID (None for bulk operations)
103
+ notification_type: str, # Notification type (assignment, payment, alert, etc.)
104
+ channel: str = "in_app", # Delivery channel (in_app, whatsapp, email, sms, push)
105
+ metadata: Optional[Dict] = None,# Additional data (action URLs, context, etc.)
106
+ project_id: Optional[UUID] = None # Optional project ID for filtering
107
+ ) -> Notification
108
+ ```
109
+
110
+ **Example:**
111
+ ```python
112
+ notification = NotificationCreator.create(
113
+ db=db,
114
+ user_id=agent.id,
115
+ title="Ticket Assigned",
116
+ message="You have been assigned to install fiber at Customer A",
117
+ source_type="ticket",
118
+ source_id=ticket.id,
119
+ notification_type="assignment",
120
+ channel="whatsapp",
121
+ project_id=ticket.project_id,
122
+ metadata={
123
+ "ticket_number": "TKT-001",
124
+ "priority": "high",
125
+ "action_url": f"/tickets/{ticket.id}"
126
+ }
127
+ )
128
+ db.commit()
129
+ ```
130
+
131
+ #### `create_bulk()` - Create Multiple Notifications
132
+
133
+ ```python
134
+ notifications = NotificationCreator.create_bulk(
135
+ db: Session,
136
+ user_ids: List[UUID], # List of users to notify
137
+ title: str, # Same title for all
138
+ message: str, # Same message for all
139
+ source_type: str,
140
+ source_id: Optional[UUID],
141
+ notification_type: str,
142
+ channel: str = "in_app",
143
+ metadata: Optional[Dict] = None,
144
+ project_id: Optional[UUID] = None
145
+ ) -> List[Notification]
146
+ ```
147
+
148
+ **Example:**
149
+ ```python
150
+ # Notify all workers about payroll export
151
+ worker_ids = [worker1.id, worker2.id, worker3.id]
152
+ notifications = NotificationCreator.create_bulk(
153
+ db=db,
154
+ user_ids=worker_ids,
155
+ title="💰 Payment Processed",
156
+ message="Your payment has been processed",
157
+ source_type="payroll",
158
+ source_id=None,
159
+ notification_type="payment",
160
+ channel="whatsapp"
161
+ )
162
+ db.commit()
163
+ ```
164
+
165
+ #### `notify_project_team()` - Notify Team Members by Role
166
+
167
+ ```python
168
+ notifications = NotificationCreator.notify_project_team(
169
+ db: Session,
170
+ project_id: UUID, # Project ID
171
+ title: str,
172
+ message: str,
173
+ source_type: str,
174
+ source_id: Optional[UUID],
175
+ notification_type: str,
176
+ roles: Optional[List[AppRole]] = None, # Filter by roles (PM, Dispatcher, etc.)
177
+ channel: str = "in_app",
178
+ metadata: Optional[Dict] = None,
179
+ exclude_user_ids: Optional[List[UUID]] = None # Exclude specific users
180
+ ) -> List[Notification]
181
+ ```
182
+
183
+ **Example:**
184
+ ```python
185
+ # Notify all managers when ticket is dropped
186
+ notifications = NotificationCreator.notify_project_team(
187
+ db=db,
188
+ project_id=ticket.project_id,
189
+ title="⚠️ Ticket Dropped - Action Required",
190
+ message=f"{agent.name} dropped ticket: {ticket.name}",
191
+ source_type="ticket",
192
+ source_id=ticket.id,
193
+ notification_type="ticket_dropped",
194
+ roles=[AppRole.PROJECT_MANAGER, AppRole.DISPATCHER],
195
+ exclude_user_ids=[agent.id] # Don't notify the agent who dropped
196
+ )
197
+ db.commit()
198
+ ```
199
+
200
+ ---
201
+
202
+ ### NotificationDelivery
203
+
204
+ #### `queue_delivery()` - Queue Single Notification
205
+
206
+ ```python
207
+ NotificationDelivery.queue_delivery(
208
+ background_tasks: BackgroundTasks, # FastAPI BackgroundTasks
209
+ notification_id: UUID # Notification ID to deliver
210
+ ) -> None
211
+ ```
212
+
213
+ **Example:**
214
+ ```python
215
+ NotificationDelivery.queue_delivery(
216
+ background_tasks=background_tasks,
217
+ notification_id=notification.id
218
+ )
219
+ ```
220
+
221
+ #### `queue_bulk_delivery()` - Queue Multiple Notifications
222
+
223
+ ```python
224
+ NotificationDelivery.queue_bulk_delivery(
225
+ background_tasks: BackgroundTasks,
226
+ notification_ids: List[UUID] # List of notification IDs
227
+ ) -> None
228
+ ```
229
+
230
+ **Example:**
231
+ ```python
232
+ NotificationDelivery.queue_bulk_delivery(
233
+ background_tasks=background_tasks,
234
+ notification_ids=[n.id for n in notifications]
235
+ )
236
+ ```
237
+
238
+ ---
239
+
240
+ ## 🎨 Common Patterns
241
+
242
+ ### Pattern 1: Single User Notification
243
+
244
+ ```python
245
+ @router.post("/tickets/assign")
246
+ def assign_ticket(
247
+ background_tasks: BackgroundTasks,
248
+ db: Session = Depends(get_db)
249
+ ):
250
+ # Business logic
251
+ assignment = create_assignment(...)
252
+
253
+ # Create notification
254
+ notification = NotificationCreator.create(
255
+ db=db,
256
+ user_id=agent.id,
257
+ title="Ticket Assigned",
258
+ message=f"You have been assigned to {ticket.name}",
259
+ source_type="ticket",
260
+ source_id=ticket.id,
261
+ notification_type="assignment",
262
+ channel="whatsapp"
263
+ )
264
+ db.commit()
265
+
266
+ # Queue delivery
267
+ NotificationDelivery.queue_delivery(
268
+ background_tasks=background_tasks,
269
+ notification_id=notification.id
270
+ )
271
+
272
+ return assignment
273
+ ```
274
+
275
+ ### Pattern 2: Notify Project Team by Role
276
+
277
+ ```python
278
+ @router.post("/tickets/{ticket_id}/drop")
279
+ def drop_ticket(
280
+ background_tasks: BackgroundTasks,
281
+ db: Session = Depends(get_db),
282
+ current_user: User = Depends(get_current_user)
283
+ ):
284
+ # Business logic
285
+ ticket = drop_ticket(...)
286
+
287
+ # Notify all managers and dispatchers
288
+ notifications = NotificationCreator.notify_project_team(
289
+ db=db,
290
+ project_id=ticket.project_id,
291
+ title="⚠️ Ticket Dropped",
292
+ message=f"{current_user.name} dropped ticket: {ticket.name}",
293
+ source_type="ticket",
294
+ source_id=ticket.id,
295
+ notification_type="ticket_dropped",
296
+ roles=[AppRole.PROJECT_MANAGER, AppRole.DISPATCHER],
297
+ exclude_user_ids=[current_user.id]
298
+ )
299
+ db.commit()
300
+
301
+ # Queue delivery
302
+ NotificationDelivery.queue_bulk_delivery(
303
+ background_tasks=background_tasks,
304
+ notification_ids=[n.id for n in notifications]
305
+ )
306
+
307
+ return ticket
308
+ ```
309
+
310
+ ---
311
+
312
+ ## ⚙️ Configuration
313
+
314
+ ### Channel Configuration
315
+
316
+ For MVP, all external channels are **disabled by default**. Notifications are created but not delivered externally.
317
+
318
+ **File:** `src/app/services/notification_delivery.py`
319
+
320
+ ```python
321
+ class NotificationDeliveryConfig:
322
+ # For MVP, all external channels are disabled
323
+ ENABLE_WHATSAPP = False # Set to True when ready
324
+ ENABLE_EMAIL = False # Set to True when ready
325
+ ENABLE_SMS = False # Set to True when ready
326
+ ENABLE_PUSH = False # Set to True when ready
327
+
328
+ # In-app notifications are always enabled
329
+ ENABLE_IN_APP = True
330
+ ```
331
+
332
+ **To enable a channel:**
333
+ 1. Set the flag to `True` in `NotificationDeliveryConfig`
334
+ 2. Implement the delivery method (e.g., `_deliver_whatsapp()`)
335
+ 3. Test thoroughly
336
+ 4. Deploy
337
+
338
+ ---
339
+
340
+ ## 🔍 Best Practices
341
+
342
+ ### 1. Always Commit Notifications
343
+
344
+ ```python
345
+ # ✅ CORRECT
346
+ notification = NotificationCreator.create(...)
347
+ db.commit() # Commit before queuing delivery
348
+
349
+ NotificationDelivery.queue_delivery(...)
350
+ ```
351
+
352
+ ```python
353
+ # ❌ WRONG
354
+ notification = NotificationCreator.create(...)
355
+ NotificationDelivery.queue_delivery(...) # Notification not committed yet!
356
+ db.commit()
357
+ ```
358
+
359
+ ### 2. Use Metadata for Context
360
+
361
+ ```python
362
+ # ✅ CORRECT - Rich metadata
363
+ notification = NotificationCreator.create(
364
+ db=db,
365
+ user_id=user.id,
366
+ title="Ticket Assigned",
367
+ message="You have been assigned...",
368
+ source_type="ticket",
369
+ source_id=ticket.id,
370
+ notification_type="assignment",
371
+ metadata={
372
+ "ticket_number": ticket.ticket_number,
373
+ "priority": "high",
374
+ "action_url": f"/tickets/{ticket.id}",
375
+ "customer_name": ticket.customer_name
376
+ }
377
+ )
378
+ ```
379
+
380
+ ### 3. Handle Notification Failures Gracefully
381
+
382
+ ```python
383
+ # ✅ CORRECT - Don't fail business logic if notification fails
384
+ try:
385
+ notification = NotificationCreator.create(...)
386
+ db.commit()
387
+ NotificationDelivery.queue_delivery(...)
388
+ except Exception as e:
389
+ logger.error(f"Failed to create notification: {e}")
390
+ # Continue with business logic
391
+ ```
392
+
393
+ ---
394
+
395
+ ## 🚀 Migration from Old System
396
+
397
+ ### Old Pattern (NotificationHelper - Async)
398
+
399
+ ```python
400
+ # ❌ OLD - Don't use this anymore
401
+ await NotificationHelper.notify_ticket_assigned(
402
+ db=db,
403
+ ticket=ticket,
404
+ agent=agent
405
+ )
406
+ ```
407
+
408
+ ### New Pattern (NotificationCreator - Sync)
409
+
410
+ ```python
411
+ # ✅ NEW - Use this instead
412
+ notification = NotificationCreator.create(
413
+ db=db,
414
+ user_id=agent.id,
415
+ title="Ticket Assigned",
416
+ message=f"You have been assigned to {ticket.name}",
417
+ source_type="ticket",
418
+ source_id=ticket.id,
419
+ notification_type="assignment",
420
+ channel="whatsapp"
421
+ )
422
+ db.commit()
423
+
424
+ NotificationDelivery.queue_delivery(
425
+ background_tasks=background_tasks,
426
+ notification_id=notification.id
427
+ )
428
+ ```
429
+
430
+ ---
431
+
432
+ ## 📞 Support
433
+
434
+ For questions or issues with the notification system:
435
+ 1. Check this documentation
436
+ 2. Review existing implementations in:
437
+ - `src/app/api/v1/payroll.py` (payroll export)
438
+ - `src/app/api/v1/ticket_assignments.py` (ticket drop)
439
+ - `src/app/services/expense_service.py` (expense submission)
440
+ 3. Contact the development team
441
+
442
+ ---
443
+
444
+ **Last Updated:** 2024-12-12
445
+ **Version:** 1.0.0
446
+ **Status:** Production Ready ✅
447
+
448
+
docs/features/REALTIME_TIMESHEET_IMPLEMENTATION.md ADDED
@@ -0,0 +1,714 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Real-Time Timesheet Updates - Implementation Guide
2
+
3
+ **Date:** 2025-12-12
4
+ **Status:** Ready for Implementation
5
+ **Estimated Time:** 6-7 hours
6
+
7
+ ---
8
+
9
+ ## Business Requirements (CONFIRMED)
10
+
11
+ ### 1. Multi-Project Support
12
+ - **Frequency:** RARE but POSSIBLE
13
+ - **Reason:** Different projects pay different rates
14
+ - **Solution:** Unique constraint `(user_id, project_id, work_date)`
15
+ - **Impact:** One agent can have multiple timesheets per day (one per project)
16
+
17
+ ### 2. Immutability Rules
18
+ - **Rule:** Past timesheets NEVER change
19
+ - **Reason:** Historical accuracy - agent's work should not be forgotten
20
+ - **Example:** Manager deletes completed ticket → Yesterday's timesheet stays unchanged
21
+ - **Implementation:** Only update timesheet if `work_date = TODAY`
22
+
23
+ ### 3. Expense Date Constraints
24
+ - **Rule:** Expenses can ONLY be submitted for TODAY
25
+ - **Reason:** Prevents agents from constructing lies
26
+ - **UI:** Expense submission button is within ticket view (same day)
27
+ - **Implementation:** API validation rejects expenses with `expense_date != TODAY`
28
+
29
+ ### 4. Check-in/Check-out Calculation
30
+ - **Check-in:** `MIN(journey_started_at)` - When agent starts first journey
31
+ - **Check-out:** `MAX(ended_at)` - When agent finishes last ticket
32
+ - **Source:** `ticket_assignments` table
33
+ - **Calculation:** Real-time aggregation from assignments
34
+
35
+ ### 5. Optimistic Locking
36
+ - **Purpose:** Prevent concurrent update conflicts
37
+ - **Implementation:** `version` column auto-incremented on every update
38
+ - **Behavior:** Update fails if version changed, retry with fresh data
39
+
40
+ ---
41
+
42
+ ## Database Schema Changes
43
+
44
+ ### Migration 1: Add Version Column to Timesheets
45
+
46
+ ```sql
47
+ -- Add version column for optimistic locking
48
+ ALTER TABLE timesheets
49
+ ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
50
+
51
+ -- Create trigger to auto-increment version on update
52
+ CREATE OR REPLACE FUNCTION increment_timesheet_version()
53
+ RETURNS TRIGGER AS $$
54
+ BEGIN
55
+ NEW.version = OLD.version + 1;
56
+ RETURN NEW;
57
+ END;
58
+ $$ LANGUAGE plpgsql;
59
+
60
+ CREATE TRIGGER trigger_increment_timesheet_version
61
+ BEFORE UPDATE ON timesheets
62
+ FOR EACH ROW
63
+ EXECUTE FUNCTION increment_timesheet_version();
64
+
65
+ -- Add index for version queries
66
+ CREATE INDEX idx_timesheets_version ON timesheets(version);
67
+ ```
68
+
69
+ ### Migration 2: Fix Unique Constraint (Multi-Project Support)
70
+
71
+ ```sql
72
+ -- Drop old constraint (user_id, work_date)
73
+ ALTER TABLE timesheets
74
+ DROP CONSTRAINT IF EXISTS uq_timesheets_user_date;
75
+
76
+ -- Add new constraint (user_id, project_id, work_date)
77
+ ALTER TABLE timesheets
78
+ ADD CONSTRAINT uq_timesheets_user_project_date
79
+ UNIQUE (user_id, project_id, work_date);
80
+
81
+ -- Note: This allows multiple timesheets per day (one per project)
82
+ ```
83
+
84
+ ### Migration 3: Add Timesheet Sync Tracking to Source Tables
85
+
86
+ ```sql
87
+ -- Add to ticket_assignments
88
+ ALTER TABLE ticket_assignments
89
+ ADD COLUMN timesheet_synced BOOLEAN NOT NULL DEFAULT FALSE,
90
+ ADD COLUMN timesheet_synced_at TIMESTAMP WITH TIME ZONE;
91
+
92
+ CREATE INDEX idx_ticket_assignments_timesheet_synced
93
+ ON ticket_assignments(timesheet_synced)
94
+ WHERE timesheet_synced = FALSE;
95
+
96
+ -- Add to ticket_expenses
97
+ ALTER TABLE ticket_expenses
98
+ ADD COLUMN timesheet_synced BOOLEAN NOT NULL DEFAULT FALSE,
99
+ ADD COLUMN timesheet_synced_at TIMESTAMP WITH TIME ZONE;
100
+
101
+ CREATE INDEX idx_ticket_expenses_timesheet_synced
102
+ ON ticket_expenses(timesheet_synced)
103
+ WHERE timesheet_synced = FALSE;
104
+
105
+ -- Add to tickets
106
+ ALTER TABLE tickets
107
+ ADD COLUMN timesheet_synced BOOLEAN NOT NULL DEFAULT FALSE,
108
+ ADD COLUMN timesheet_synced_at TIMESTAMP WITH TIME ZONE;
109
+
110
+ CREATE INDEX idx_tickets_timesheet_synced
111
+ ON tickets(timesheet_synced)
112
+ WHERE timesheet_synced = FALSE;
113
+ ```
114
+
115
+ ### Migration 4: Add Missing Expense Columns to Timesheets
116
+
117
+ ```sql
118
+ -- Check if expense columns exist, add if missing
119
+ ALTER TABLE timesheets
120
+ ADD COLUMN IF NOT EXISTS total_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
121
+ ADD COLUMN IF NOT EXISTS approved_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
122
+ ADD COLUMN IF NOT EXISTS pending_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
123
+ ADD COLUMN IF NOT EXISTS rejected_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
124
+ ADD COLUMN IF NOT EXISTS expense_claims_count INTEGER DEFAULT 0 NOT NULL;
125
+
126
+ -- Add comments
127
+ COMMENT ON COLUMN timesheets.total_expenses IS 'Total expense amount claimed this day';
128
+ COMMENT ON COLUMN timesheets.approved_expenses IS 'Approved expense amount';
129
+ COMMENT ON COLUMN timesheets.pending_expenses IS 'Pending approval amount';
130
+ COMMENT ON COLUMN timesheets.rejected_expenses IS 'Rejected expense amount';
131
+ COMMENT ON COLUMN timesheets.expense_claims_count IS 'Number of expense claims submitted';
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Core Service Design
137
+
138
+ ### Service: `TimesheetRealtimeService`
139
+
140
+ **File:** `src/app/services/timesheet_realtime_service.py`
141
+
142
+ **Key Methods:**
143
+
144
+ 1. `update_timesheet_for_event(db, user_id, project_id, work_date, event_type, entity_type, entity_id)`
145
+ - Main entry point for all timesheet updates
146
+ - Validates work_date == today (immutability rule)
147
+ - Calls aggregation methods
148
+ - Upserts timesheet with optimistic locking
149
+ - Marks source record as synced
150
+
151
+
152
+ ### Ticket Completion (1 event)
153
+
154
+ **File:** `src/app/api/v1/ticket_completion.py`
155
+
156
+ 7. **Ticket Marked Complete**
157
+ - Trigger: After ticket status changed to completed
158
+ - Updates: All assignments for this ticket
159
+ - Date: `ticket.completed_at.date()`
160
+ - Note: May update multiple agents' timesheets
161
+
162
+ ### Ticket Expenses (4 events)
163
+
164
+ **File:** `src/app/api/v1/ticket_expenses.py`
165
+
166
+ 8. **Expense Created** (agent submits)
167
+ - Trigger: After expense created
168
+ - Updates: `total_expenses`, `pending_expenses`, `expense_claims_count`
169
+ - Date: `expense.expense_date` (must be TODAY)
170
+ - Validation: Reject if `expense.expense_date != TODAY`
171
+
172
+ 9. **Expense Updated** (agent edits before approval)
173
+ - Trigger: After expense amount changed
174
+ - Updates: Recalculate expense totals
175
+ - Date: `expense.expense_date`
176
+
177
+ 10. **Expense Approved** (manager approves)
178
+ - Trigger: After approval
179
+ - Updates: Move from `pending_expenses` to `approved_expenses`
180
+ - Date: `expense.expense_date`
181
+
182
+ 11. **Expense Rejected** (manager rejects)
183
+ - Trigger: After rejection
184
+ - Updates: Move from `pending_expenses` to `rejected_expenses`
185
+ - Date: `expense.expense_date`
186
+
187
+ ---
188
+
189
+ ## Implementation Details
190
+
191
+ ### Immutability Enforcement
192
+
193
+ ```python
194
+ def update_timesheet_for_event(
195
+ db: Session,
196
+ user_id: UUID,
197
+ project_id: UUID,
198
+ work_date: date,
199
+ event_type: str,
200
+ entity_type: str,
201
+ entity_id: UUID
202
+ ) -> Optional[UUID]:
203
+ """
204
+ Update timesheet for an event.
205
+
206
+ IMMUTABILITY RULE: Only update if work_date == today
207
+ """
208
+ from datetime import date as date_class
209
+
210
+ # Enforce immutability
211
+ if work_date < date_class.today():
212
+ logger.warning(
213
+ f"Skipping timesheet update for past date: {work_date}. "
214
+ f"Event: {event_type}, Entity: {entity_type}:{entity_id}"
215
+ )
216
+ return None
217
+
218
+ # Only update today's timesheet
219
+ if work_date > date_class.today():
220
+ logger.warning(
221
+ f"Skipping timesheet update for future date: {work_date}. "
222
+ f"Event: {event_type}, Entity: {entity_type}:{entity_id}"
223
+ )
224
+ return None
225
+
226
+ # Proceed with update...
227
+ ```
228
+
229
+ ### Optimistic Locking with Retry
230
+
231
+ ```python
232
+ def _upsert_timesheet_with_lock(
233
+ db: Session,
234
+ user_id: UUID,
235
+ project_id: UUID,
236
+ work_date: date,
237
+ metrics: dict,
238
+ max_retries: int = 3
239
+ ) -> Optional[UUID]:
240
+ """
241
+ Upsert timesheet with optimistic locking.
242
+ Retries on version conflict.
243
+ """
244
+ for attempt in range(max_retries):
245
+ try:
246
+ # Get existing timesheet
247
+ existing = db.query(Timesheet).filter(
248
+ Timesheet.user_id == user_id,
249
+ Timesheet.project_id == project_id,
250
+ Timesheet.work_date == work_date,
251
+ Timesheet.deleted_at.is_(None)
252
+ ).first()
253
+
254
+ if existing:
255
+ # UPDATE with version check
256
+ current_version = existing.version
257
+
258
+ result = db.query(Timesheet).filter(
259
+ Timesheet.id == existing.id,
260
+ Timesheet.version == current_version # Optimistic lock
261
+ ).update(metrics)
262
+
263
+ if result == 0:
264
+ # Version conflict - another update happened
265
+ logger.warning(
266
+ f"Optimistic lock conflict on timesheet {existing.id}. "
267
+ f"Retry {attempt + 1}/{max_retries}"
268
+ )
269
+ db.rollback()
270
+ continue # Retry
271
+
272
+ db.commit()
273
+ return existing.id
274
+ else:
275
+ # INSERT new timesheet
276
+ new_timesheet = Timesheet(
277
+ user_id=user_id,
278
+ project_id=project_id,
279
+ work_date=work_date,
280
+ version=1,
281
+ **metrics
282
+ )
283
+ db.add(new_timesheet)
284
+ db.commit()
285
+ return new_timesheet.id
286
+
287
+ except Exception as e:
288
+ logger.error(f"Error upserting timesheet: {e}")
289
+ db.rollback()
290
+ if attempt == max_retries - 1:
291
+ return None
292
+ continue
293
+
294
+ return None
295
+ ```
296
+
297
+ ### Aggregation Queries
298
+
299
+ ```python
300
+ def _aggregate_ticket_metrics(
301
+ db: Session,
302
+ user_id: UUID,
303
+ project_id: UUID,
304
+ work_date: date
305
+ ) -> dict:
306
+ """
307
+ Aggregate ticket metrics for a specific day.
308
+
309
+ Uses MIN(journey_started_at) for check-in
310
+ Uses MAX(ended_at) for check-out
311
+ """
312
+ from sqlalchemy import func, case
313
+ from app.models.ticket_assignment import TicketAssignment
314
+ from app.models.ticket import Ticket
315
+
316
+ # Query assignments for this day
317
+ query = db.query(
318
+ # Count by action type
319
+ func.count(
320
+ case((TicketAssignment.action == 'assigned', 1))
321
+ ).label('tickets_assigned'),
322
+ func.count(
323
+ case((TicketAssignment.action == 'completed', 1))
324
+ ).label('tickets_completed'),
325
+ func.count(
326
+ case((TicketAssignment.action == 'rejected', 1))
327
+ ).label('tickets_rejected'),
328
+ func.count(
329
+ case((TicketAssignment.action == 'dropped', 1))
330
+ ).label('tickets_cancelled'),
331
+
332
+ # Check-in/out times
333
+ func.min(TicketAssignment.journey_started_at).label('check_in_time'),
334
+ func.max(TicketAssignment.ended_at).label('check_out_time')
335
+ ).join(
336
+ Ticket, TicketAssignment.ticket_id == Ticket.id
337
+ ).filter(
338
+ TicketAssignment.user_id == user_id,
339
+ Ticket.project_id == project_id,
340
+ func.date(TicketAssignment.assigned_at) == work_date,
341
+ TicketAssignment.deleted_at.is_(None),
342
+ Ticket.deleted_at.is_(None)
343
+ )
344
+
345
+ result = query.first()
346
+
347
+ # Calculate hours worked
348
+ hours_worked = None
349
+ if result.check_in_time and result.check_out_time:
350
+ delta = result.check_out_time - result.check_in_time
351
+ hours_worked = round(delta.total_seconds() / 3600, 2)
352
+
353
+ return {
354
+ 'tickets_assigned': result.tickets_assigned or 0,
355
+ 'tickets_completed': result.tickets_completed or 0,
356
+ 'tickets_rejected': result.tickets_rejected or 0,
357
+ 'tickets_cancelled': result.tickets_cancelled or 0,
358
+ 'check_in_time': result.check_in_time,
359
+ 'check_out_time': result.check_out_time,
360
+ 'hours_worked': hours_worked
361
+ }
362
+
363
+
364
+ def _aggregate_expense_metrics(
365
+ db: Session,
366
+ user_id: UUID,
367
+ project_id: UUID,
368
+ work_date: date
369
+ ) -> dict:
370
+ """
371
+ Aggregate expense metrics for a specific day.
372
+ """
373
+ from sqlalchemy import func, case
374
+ from app.models.ticket_expense import TicketExpense
375
+ from app.models.ticket import Ticket
376
+
377
+ query = db.query(
378
+ func.count(TicketExpense.id).label('expense_claims_count'),
379
+ func.sum(TicketExpense.amount).label('total_expenses'),
380
+ func.sum(
381
+ case((TicketExpense.is_approved == True, TicketExpense.amount), else_=0)
382
+ ).label('approved_expenses'),
383
+ func.sum(
384
+ case((TicketExpense.is_approved.is_(None), TicketExpense.amount), else_=0)
385
+ ).label('pending_expenses'),
386
+ func.sum(
387
+ case((TicketExpense.is_approved == False, TicketExpense.amount), else_=0)
388
+ ).label('rejected_expenses')
389
+ ).join(
390
+ Ticket, TicketExpense.ticket_id == Ticket.id
391
+ ).filter(
392
+ TicketExpense.incurred_by_user_id == user_id,
393
+ Ticket.project_id == project_id,
394
+ TicketExpense.expense_date == work_date,
395
+ TicketExpense.deleted_at.is_(None),
396
+ Ticket.deleted_at.is_(None)
397
+ )
398
+
399
+ result = query.first()
400
+
401
+ return {
402
+ 'expense_claims_count': result.expense_claims_count or 0,
403
+ 'total_expenses': float(result.total_expenses or 0),
404
+ 'approved_expenses': float(result.approved_expenses or 0),
405
+ 'pending_expenses': float(result.pending_expenses or 0),
406
+ 'rejected_expenses': float(result.rejected_expenses or 0)
407
+ }
408
+ ```
409
+
410
+ ---
411
+
412
+ ## Error Handling Strategy
413
+
414
+ ### Graceful Degradation
415
+
416
+ ```python
417
+ # In API endpoints (ticket_assignments.py, ticket_expenses.py, etc.)
418
+
419
+ try:
420
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
421
+ from datetime import date
422
+
423
+ # Main operation already succeeded
424
+ # Timesheet update is best-effort
425
+
426
+ timesheet_id = update_timesheet_for_event(
427
+ db=db,
428
+ user_id=assignment.user_id,
429
+ project_id=assignment.ticket.project_id,
430
+ work_date=date.today(),
431
+ event_type='assignment_created',
432
+ entity_type='ticket_assignment',
433
+ entity_id=assignment.id
434
+ )
435
+
436
+ if timesheet_id:
437
+ logger.info(f"Timesheet {timesheet_id} updated for assignment {assignment.id}")
438
+ else:
439
+ logger.warning(f"Failed to update timesheet for assignment {assignment.id}")
440
+
441
+ except Exception as e:
442
+ # Don't let timesheet failures block main operation
443
+ logger.error(f"Timesheet update error: {e}", exc_info=True)
444
+ # Continue - main operation already succeeded
445
+ ```
446
+
447
+ ### Sync Tracking
448
+
449
+ ```python
450
+ def _mark_entity_synced(
451
+ db: Session,
452
+ entity_type: str,
453
+ entity_id: UUID
454
+ ) -> bool:
455
+ """
456
+ Mark source entity as synced to timesheet.
457
+ """
458
+ try:
459
+ if entity_type == 'ticket_assignment':
460
+ db.query(TicketAssignment).filter(
461
+ TicketAssignment.id == entity_id
462
+ ).update({
463
+ 'timesheet_synced': True,
464
+ 'timesheet_synced_at': func.now()
465
+ })
466
+ elif entity_type == 'ticket_expense':
467
+ db.query(TicketExpense).filter(
468
+ TicketExpense.id == entity_id
469
+ ).update({
470
+ 'timesheet_synced': True,
471
+ 'timesheet_synced_at': func.now()
472
+ })
473
+ elif entity_type == 'ticket':
474
+ db.query(Ticket).filter(
475
+ Ticket.id == entity_id
476
+ ).update({
477
+ 'timesheet_synced': True,
478
+ 'timesheet_synced_at': func.now()
479
+ })
480
+
481
+ db.commit()
482
+ return True
483
+ except Exception as e:
484
+ logger.error(f"Failed to mark {entity_type}:{entity_id} as synced: {e}")
485
+ db.rollback()
486
+ return False
487
+ ```
488
+
489
+ ---
490
+
491
+ ## Manual Reconciliation API
492
+
493
+ ### Endpoint 1: Find Unsynced Records
494
+
495
+ ```python
496
+ @router.get("/timesheets/unsynced-records")
497
+ async def get_unsynced_records(
498
+ entity_type: str = Query(..., regex="^(assignment|expense|ticket)$"),
499
+ project_id: Optional[UUID] = None,
500
+ since: Optional[datetime] = None,
501
+ limit: int = Query(100, le=1000),
502
+ db: Session = Depends(get_db),
503
+ current_user: User = Depends(get_current_user)
504
+ ):
505
+ """
506
+ Find records that failed to sync to timesheets.
507
+
508
+ Authorization: Project Manager, Dispatcher, Platform Admin
509
+ """
510
+ # Check permissions
511
+ if current_user.role not in ['project_manager', 'dispatcher', 'platform_admin']:
512
+ raise HTTPException(status_code=403, detail="Insufficient permissions")
513
+
514
+ # Query based on entity type
515
+ if entity_type == 'assignment':
516
+ query = db.query(TicketAssignment).filter(
517
+ TicketAssignment.timesheet_synced == False,
518
+ TicketAssignment.deleted_at.is_(None)
519
+ )
520
+ if project_id:
521
+ query = query.join(Ticket).filter(Ticket.project_id == project_id)
522
+ if since:
523
+ query = query.filter(TicketAssignment.created_at >= since)
524
+
525
+ # ... similar for expense and ticket
526
+
527
+ records = query.limit(limit).all()
528
+
529
+ return {
530
+ "entity_type": entity_type,
531
+ "count": len(records),
532
+ "records": records
533
+ }
534
+ ```
535
+
536
+ ### Endpoint 2: Reconcile Unsynced Records
537
+
538
+ ```python
539
+ @router.post("/timesheets/reconcile-unsynced")
540
+ async def reconcile_unsynced_records(
541
+ entity_type: str = Query(..., regex="^(assignment|expense|ticket)$"),
542
+ project_id: Optional[UUID] = None,
543
+ limit: int = Query(100, le=1000),
544
+ db: Session = Depends(get_db),
545
+ current_user: User = Depends(get_current_user)
546
+ ):
547
+ """
548
+ Manually reconcile unsynced records.
549
+
550
+ Authorization: Project Manager, Platform Admin
551
+ """
552
+ # Check permissions
553
+ if current_user.role not in ['project_manager', 'platform_admin']:
554
+ raise HTTPException(status_code=403, detail="Insufficient permissions")
555
+
556
+ # Find unsynced records
557
+ # ... (same query as above)
558
+
559
+ success_count = 0
560
+ error_count = 0
561
+ errors = []
562
+
563
+ for record in records:
564
+ try:
565
+ timesheet_id = update_timesheet_for_event(
566
+ db=db,
567
+ user_id=record.user_id,
568
+ project_id=record.ticket.project_id,
569
+ work_date=record.assigned_at.date(),
570
+ event_type='manual_reconciliation',
571
+ entity_type=entity_type,
572
+ entity_id=record.id
573
+ )
574
+
575
+ if timesheet_id:
576
+ success_count += 1
577
+ else:
578
+ error_count += 1
579
+ errors.append(f"Failed to sync {entity_type}:{record.id}")
580
+
581
+ except Exception as e:
582
+ error_count += 1
583
+ errors.append(f"Error syncing {entity_type}:{record.id}: {str(e)}")
584
+
585
+ return {
586
+ "total_found": len(records),
587
+ "success_count": success_count,
588
+ "error_count": error_count,
589
+ "errors": errors
590
+ }
591
+ ```
592
+
593
+ ---
594
+
595
+ ## Testing Checklist
596
+
597
+ ### Unit Tests
598
+
599
+ - [ ] Test aggregation queries return correct counts
600
+ - [ ] Test optimistic locking conflict handling
601
+ - [ ] Test immutability enforcement (skip past dates)
602
+ - [ ] Test multi-project support (separate timesheets)
603
+ - [ ] Test check-in/out calculation
604
+ - [ ] Test expense metrics aggregation
605
+
606
+ ### Integration Tests
607
+
608
+ - [ ] Create assignment → Verify timesheet updated
609
+ - [ ] Complete ticket → Verify timesheet updated
610
+ - [ ] Create expense → Verify timesheet updated
611
+ - [ ] Approve expense → Verify pending moved to approved
612
+ - [ ] Multi-project same day → Verify two timesheets
613
+ - [ ] Concurrent updates → Verify no lost updates
614
+
615
+ ### Edge Cases
616
+
617
+ - [ ] Past date event → Verify timesheet NOT updated
618
+ - [ ] Future date event → Verify timesheet NOT updated
619
+ - [ ] Deleted ticket → Verify past timesheet unchanged
620
+ - [ ] Database error → Verify main operation succeeds
621
+ - [ ] Unsynced records → Verify manual reconciliation works
622
+
623
+ ---
624
+
625
+ ## Deployment Checklist
626
+
627
+ ### Pre-Deployment
628
+
629
+ - [ ] Run database migrations in staging
630
+ - [ ] Verify no constraint violations
631
+ - [ ] Test all integration points
632
+ - [ ] Review error handling
633
+ - [ ] Check performance (query times)
634
+
635
+ ### Deployment
636
+
637
+ - [ ] Backup database
638
+ - [ ] Run migrations in production
639
+ - [ ] Deploy code changes
640
+ - [ ] Monitor error logs for 1 hour
641
+ - [ ] Spot-check timesheet accuracy
642
+
643
+ ### Post-Deployment
644
+
645
+ - [ ] Verify no errors in logs
646
+ - [ ] Check unsynced record count
647
+ - [ ] Test payroll calculation
648
+ - [ ] Monitor performance metrics
649
+ - [ ] Document any issues
650
+
651
+ ---
652
+
653
+ **Ready to implement!** 🚀
654
+
655
+ 2. `_aggregate_ticket_metrics(db, user_id, project_id, work_date)`
656
+ - Counts assignments by action type
657
+ - Returns: tickets_assigned, tickets_completed, tickets_rejected, tickets_cancelled
658
+
659
+ 3. `_aggregate_expense_metrics(db, user_id, project_id, work_date)`
660
+ - Sums expenses by approval status
661
+ - Returns: total_expenses, approved_expenses, pending_expenses, rejected_expenses, expense_claims_count
662
+
663
+ 4. `_calculate_check_times(db, user_id, project_id, work_date)`
664
+ - MIN(journey_started_at) as check_in
665
+ - MAX(ended_at) as check_out
666
+ - Returns: check_in_time, check_out_time, hours_worked
667
+
668
+ 5. `_upsert_timesheet_with_lock(db, user_id, project_id, work_date, metrics, version)`
669
+ - Upserts timesheet with optimistic locking
670
+ - Retries on version conflict (max 3 attempts)
671
+ - Returns: timesheet_id or None
672
+
673
+ 6. `_mark_entity_synced(db, entity_type, entity_id)`
674
+ - Updates source record: timesheet_synced = TRUE, timesheet_synced_at = NOW()
675
+
676
+ ---
677
+
678
+ ## Integration Points (11 Total)
679
+
680
+ ### Ticket Assignments (6 events)
681
+
682
+ **File:** `src/app/api/v1/ticket_assignments.py`
683
+
684
+ 1. **Assignment Created** (dispatcher assigns)
685
+ - Trigger: After assignment created
686
+ - Updates: `tickets_assigned`
687
+ - Date: `assignment.assigned_at.date()`
688
+
689
+ 2. **Self-Assignment** (agent picks ticket)
690
+ - Trigger: After self-assignment
691
+ - Updates: `tickets_assigned`
692
+ - Date: `assignment.assigned_at.date()`
693
+
694
+ 3. **Assignment Accepted** (agent accepts)
695
+ - Trigger: After status change to accepted
696
+ - Updates: Refresh check-in time
697
+ - Date: `assignment.assigned_at.date()`
698
+
699
+ 4. **Assignment Rejected** (agent rejects)
700
+ - Trigger: After status change to rejected
701
+ - Updates: `tickets_rejected`
702
+ - Date: `assignment.assigned_at.date()`
703
+
704
+ 5. **Assignment Dropped** (agent can't complete)
705
+ - Trigger: After status change to dropped
706
+ - Updates: `tickets_cancelled`
707
+ - Date: `assignment.ended_at.date()`
708
+
709
+ 6. **Assignment Completed** (agent finishes)
710
+ - Trigger: After status change to completed
711
+ - Updates: `tickets_completed`
712
+ - Date: `assignment.ended_at.date()`
713
+
714
+
src/app/api/v1/expenses.py CHANGED
@@ -10,7 +10,7 @@ Provides endpoints for:
10
  - Statistics and reporting
11
  """
12
 
13
- from fastapi import APIRouter, Depends, HTTPException, status, Query
14
  from sqlalchemy.orm import Session
15
  from typing import Optional, List
16
  from uuid import UUID
@@ -254,12 +254,13 @@ def update_expense(
254
  def approve_expense(
255
  expense_id: UUID,
256
  data: TicketExpenseApprove,
 
257
  current_user: User = Depends(get_current_user),
258
  db: Session = Depends(get_db)
259
  ):
260
  """Approve or reject expense"""
261
  try:
262
- expense = ExpenseService.approve_expense(db, expense_id, data, current_user.id)
263
 
264
  response = TicketExpenseResponse.model_validate(expense)
265
  response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
@@ -375,12 +376,13 @@ def update_payment_details(
375
  def mark_expense_paid(
376
  expense_id: UUID,
377
  data: TicketExpenseMarkPaid,
 
378
  current_user: User = Depends(get_current_user),
379
  db: Session = Depends(get_db)
380
  ):
381
  """Mark expense as paid"""
382
  try:
383
- expense = ExpenseService.mark_paid(db, expense_id, data)
384
 
385
  response = TicketExpenseResponse.model_validate(expense)
386
  response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
 
10
  - Statistics and reporting
11
  """
12
 
13
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
14
  from sqlalchemy.orm import Session
15
  from typing import Optional, List
16
  from uuid import UUID
 
254
  def approve_expense(
255
  expense_id: UUID,
256
  data: TicketExpenseApprove,
257
+ background_tasks: BackgroundTasks,
258
  current_user: User = Depends(get_current_user),
259
  db: Session = Depends(get_db)
260
  ):
261
  """Approve or reject expense"""
262
  try:
263
+ expense = ExpenseService.approve_expense(db, expense_id, data, current_user.id, background_tasks)
264
 
265
  response = TicketExpenseResponse.model_validate(expense)
266
  response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
 
376
  def mark_expense_paid(
377
  expense_id: UUID,
378
  data: TicketExpenseMarkPaid,
379
+ background_tasks: BackgroundTasks,
380
  current_user: User = Depends(get_current_user),
381
  db: Session = Depends(get_db)
382
  ):
383
  """Mark expense as paid"""
384
  try:
385
+ expense = ExpenseService.mark_paid(db, expense_id, data, background_tasks)
386
 
387
  response = TicketExpenseResponse.model_validate(expense)
388
  response.incurred_by_user_name = expense.incurred_by_user.full_name if expense.incurred_by_user else None
src/app/api/v1/invoice_generation.py CHANGED
@@ -7,7 +7,7 @@ Endpoints for:
7
  - Exporting invoices to CSV
8
  - Regenerating viewing tokens
9
  """
10
- from fastapi import APIRouter, Depends, HTTPException, Response, Query
11
  from sqlalchemy.orm import Session
12
  from typing import List, Optional
13
  from uuid import UUID
@@ -18,6 +18,7 @@ from app.api.deps import get_db, get_current_user
18
  from app.models.user import User
19
  from app.models.enums import AppRole
20
  from app.services.invoice_generation_service import InvoiceGenerationService
 
21
  from app.schemas.invoice_generation import (
22
  InvoiceGenerateRequest,
23
  AvailableTicketResponse,
@@ -121,6 +122,7 @@ def get_available_tickets(
121
  @router.post("/generate", response_model=InvoiceGenerateResponse)
122
  def generate_invoice(
123
  data: InvoiceGenerateRequest,
 
124
  db: Session = Depends(get_db),
125
  current_user: User = Depends(get_current_user)
126
  ):
@@ -181,8 +183,8 @@ def generate_invoice(
181
  )
182
 
183
  check_invoice_permission(current_user, contractor_id)
184
-
185
- invoice, viewing_link, csv_link = InvoiceGenerationService.generate_invoice_from_tickets(
186
  db=db,
187
  contractor_id=contractor_id,
188
  client_id=client_id,
@@ -191,7 +193,14 @@ def generate_invoice(
191
  invoice_metadata=data.dict(exclude={'ticket_ids', 'contractor_id', 'client_id', 'project_id'}),
192
  current_user=current_user
193
  )
194
-
 
 
 
 
 
 
 
195
  return InvoiceGenerateResponse(
196
  success=True,
197
  invoice_id=invoice.id,
 
7
  - Exporting invoices to CSV
8
  - Regenerating viewing tokens
9
  """
10
+ from fastapi import APIRouter, Depends, HTTPException, Response, Query, BackgroundTasks
11
  from sqlalchemy.orm import Session
12
  from typing import List, Optional
13
  from uuid import UUID
 
18
  from app.models.user import User
19
  from app.models.enums import AppRole
20
  from app.services.invoice_generation_service import InvoiceGenerationService
21
+ from app.services.notification_delivery import NotificationDelivery
22
  from app.schemas.invoice_generation import (
23
  InvoiceGenerateRequest,
24
  AvailableTicketResponse,
 
122
  @router.post("/generate", response_model=InvoiceGenerateResponse)
123
  def generate_invoice(
124
  data: InvoiceGenerateRequest,
125
+ background_tasks: BackgroundTasks,
126
  db: Session = Depends(get_db),
127
  current_user: User = Depends(get_current_user)
128
  ):
 
183
  )
184
 
185
  check_invoice_permission(current_user, contractor_id)
186
+
187
+ invoice, viewing_link, csv_link, notification_ids = InvoiceGenerationService.generate_invoice_from_tickets(
188
  db=db,
189
  contractor_id=contractor_id,
190
  client_id=client_id,
 
193
  invoice_metadata=data.dict(exclude={'ticket_ids', 'contractor_id', 'client_id', 'project_id'}),
194
  current_user=current_user
195
  )
196
+
197
+ # Queue notification delivery (Tier 2 - Asynchronous)
198
+ if notification_ids:
199
+ NotificationDelivery.queue_bulk_delivery(
200
+ background_tasks=background_tasks,
201
+ notification_ids=notification_ids
202
+ )
203
+
204
  return InvoiceGenerateResponse(
205
  success=True,
206
  invoice_id=invoice.id,
src/app/api/v1/payroll.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Payroll API Endpoints - Weekly payroll generation and management
3
  """
4
- from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
5
  from sqlalchemy.orm import Session
6
  from typing import Optional, List
7
  from uuid import UUID
@@ -25,6 +25,8 @@ from app.schemas.payroll import (
25
  )
26
  from app.services.payroll_service import PayrollService
27
  from app.services.audit_service import AuditService
 
 
28
  from app.core.permissions import require_permission
29
 
30
  logger = logging.getLogger(__name__)
@@ -60,15 +62,18 @@ async def generate_payroll(
60
  **Business Rules:**
61
  - Pay period must be exactly 7 days (Monday-Sunday)
62
  - Cannot regenerate if already paid (unless force flag)
63
- - Aggregates data from timesheets and ticket_assignments
64
  - Uses compensation rates from project_roles
65
-
66
  **Calculation:**
67
- - flat_rate_amount: From project_role (weekly rate)
68
- - ticket_earnings: (base_amount * tickets_closed) + commission
69
- - hours_worked: Sum from timesheets (PRESENT status)
70
- - days_worked: Count of PRESENT days
71
- - total_amount: flat_rate + ticket_earnings + bonus - deductions
 
 
 
72
 
73
  **Response:**
74
  - success: True if generated, False if skipped
@@ -467,3 +472,277 @@ async def mark_payroll_as_paid(
467
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
468
  detail=f"Failed to mark payroll as paid: {str(e)}"
469
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Payroll API Endpoints - Weekly payroll generation and management
3
  """
4
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, BackgroundTasks
5
  from sqlalchemy.orm import Session
6
  from typing import Optional, List
7
  from uuid import UUID
 
25
  )
26
  from app.services.payroll_service import PayrollService
27
  from app.services.audit_service import AuditService
28
+ from app.services.notification_creator import NotificationCreator
29
+ from app.services.notification_delivery import NotificationDelivery
30
  from app.core.permissions import require_permission
31
 
32
  logger = logging.getLogger(__name__)
 
62
  **Business Rules:**
63
  - Pay period must be exactly 7 days (Monday-Sunday)
64
  - Cannot regenerate if already paid (unless force flag)
65
+ - Aggregates data from timesheets
66
  - Uses compensation rates from project_roles
67
+
68
  **Calculation:**
69
+ - base_earnings: Calculated from compensation_type:
70
+ * FIXED_RATE: days_worked × base_rate (or hours × base_rate for hourly)
71
+ * PER_UNIT: tickets_closed × per_unit_rate
72
+ * COMMISSION: ticket_value × commission_percentage
73
+ * FIXED_PLUS_COMMISSION: (days × base_rate) + (ticket_value × commission%)
74
+ - days_worked: Count of PRESENT days from timesheets
75
+ - tickets_closed: Count from timesheet ticket metrics
76
+ - total_amount: base_earnings + bonus - deductions
77
 
78
  **Response:**
79
  - success: True if generated, False if skipped
 
472
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
473
  detail=f"Failed to mark payroll as paid: {str(e)}"
474
  )
475
+
476
+
477
+ # ============================================
478
+ # PAYROLL EXPORT FOR PAYMENT
479
+ # ============================================
480
+
481
+ @router.post("/export", status_code=status.HTTP_200_OK)
482
+ @require_permission("manage_payroll")
483
+ def export_payroll_for_payment(
484
+ background_tasks: BackgroundTasks,
485
+ from_date: date = Query(..., description="Period start date (inclusive)"),
486
+ to_date: date = Query(..., description="Period end date (inclusive)"),
487
+ project_id: Optional[UUID] = Query(None, description="Filter by project"),
488
+ user_id: Optional[UUID] = Query(None, description="Filter by user"),
489
+ request: Request = None,
490
+ current_user: User = Depends(get_current_active_user),
491
+ db: Session = Depends(get_db)
492
+ ):
493
+ """
494
+ Export unpaid payroll to Tende Pay CSV format and mark as paid.
495
+
496
+ **How Payroll Works:**
497
+ 1. Payroll is generated/updated in REAL-TIME when timesheets are created/updated
498
+ 2. When timesheet is created, system checks if payroll exists for that user+project+period
499
+ 3. If exists → UPDATE existing payroll | If not → CREATE new payroll
500
+ 4. On payment day, manager exports payroll for the period
501
+ 5. System retrieves MOST RECENT payout details at export time (not stored in payroll)
502
+ 6. Manager reviews CSV + warnings, then exports
503
+ 7. System marks exported payroll records as paid
504
+
505
+ **Authorization:**
506
+ - Platform admins
507
+ - Project managers
508
+
509
+ **Query Parameters:**
510
+ - from_date: Period start date (filters by period_start_date >= from_date)
511
+ - to_date: Period end date (filters by period_end_date <= to_date)
512
+ - project_id: Optional - filter by specific project
513
+ - user_id: Optional - filter by specific user
514
+
515
+ **Business Rules:**
516
+ - Only exports unpaid payroll (is_paid = false)
517
+ - Retrieves payment details from user's primary financial account AT EXPORT TIME
518
+ - Missing fields generate WARNINGS but do NOT fail the export
519
+ - ALL payroll records are exported (even with missing payment details)
520
+ - Marks all exported payroll as paid (irreversible)
521
+ - Sets payment_reference to PAYROLL_EXPORT_{timestamp}_{user_id}
522
+
523
+ **Payment Details (Retrieved at Export Time):**
524
+ - Fetched from user_financial_accounts (is_primary = true, is_active = true)
525
+ - Supports: mobile_money (M-Pesa), bank_transfer
526
+ - Missing/invalid details → WARNING (not failure)
527
+ - Manager can manually fix CSV before uploading to Tende Pay
528
+
529
+ **Narration Format:**
530
+ - "Payroll {period}: {days} days worked, {tickets} tickets closed, {amount} KES"
531
+ - Example: "Payroll 2024-12-02 to 2024-12-08: 5 days worked, 12 tickets closed, 5000 KES"
532
+
533
+ **Response:**
534
+ - CSV file with Tende Pay bulk payment format
535
+ - Warnings included as comments at end of file
536
+ - X-Warning-Count header with number of warnings
537
+ - X-Exported-Count header with number of payroll records exported
538
+
539
+ **Warnings (Non-Blocking):**
540
+ - User has no financial account → exported with empty payment details
541
+ - Invalid payment details → exported with warning
542
+ - Missing ID number → exported with empty ID field
543
+ - Unsupported payment method → exported with empty payment details
544
+
545
+ **Example:**
546
+ ```
547
+ POST /api/v1/payroll/export?from_date=2024-12-02&to_date=2024-12-08&project_id=xxx
548
+ ```
549
+ """
550
+ import csv
551
+ import io
552
+ from datetime import datetime
553
+ from fastapi.responses import StreamingResponse
554
+
555
+ try:
556
+ # Export payroll
557
+ csv_rows, warnings, exported_payroll_records = PayrollService.export_for_payment(
558
+ db=db,
559
+ from_date=from_date,
560
+ to_date=to_date,
561
+ current_user=current_user,
562
+ project_id=project_id,
563
+ user_id=user_id
564
+ )
565
+
566
+ # Generate Tende Pay CSV
567
+ output = io.StringIO()
568
+ if csv_rows:
569
+ # Tende Pay column order (must match template exactly)
570
+ fieldnames = [
571
+ "NAME",
572
+ "ID NUMBER",
573
+ "PHONE NUMBER",
574
+ "AMOUNT",
575
+ "PAYMENT MODE",
576
+ "BANK (Optional)",
577
+ "BANK ACCOUNT NO (Optional)",
578
+ "PAYBILL BUSINESS NO (Optional)",
579
+ "PAYBILL ACCOUNT NO (Optional)",
580
+ "BUY GOODS TILL NO (Optional)",
581
+ "BILL PAYMENT BILLER CODE (Optional)",
582
+ "BILL PAYMENT ACCOUNT NO (Optional)",
583
+ "NARRATION (OPTIONAL)"
584
+ ]
585
+ writer = csv.DictWriter(output, fieldnames=fieldnames)
586
+ writer.writeheader()
587
+ writer.writerows(csv_rows)
588
+
589
+ # Add warnings as comments at the end
590
+ if warnings:
591
+ output.write("\n# WARNINGS:\n")
592
+ for warning in warnings:
593
+ output.write(f"# {warning}\n")
594
+
595
+ output.seek(0)
596
+ filename = f"tende_pay_payroll_{from_date.isoformat()}_{to_date.isoformat()}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
597
+
598
+ # Log audit trail (keep async for now - separate concern)
599
+ await AuditService.log_action(
600
+ db=db,
601
+ user_id=current_user.id,
602
+ action="export_payroll_for_payment",
603
+ resource_type="payroll",
604
+ resource_id=None,
605
+ details={
606
+ "from_date": from_date.isoformat(),
607
+ "to_date": to_date.isoformat(),
608
+ "project_id": str(project_id) if project_id else None,
609
+ "user_id": str(user_id) if user_id else None,
610
+ "exported_count": len(csv_rows),
611
+ "warning_count": len(warnings)
612
+ },
613
+ ip_address=request.client.host if request and request.client else None
614
+ )
615
+
616
+ logger.info(
617
+ f"Exported {len(csv_rows)} payroll records for payment "
618
+ f"(period: {from_date} to {to_date}, warnings: {len(warnings)})"
619
+ )
620
+
621
+ # Create notifications (Tier 1 - Synchronous)
622
+ notifications_created = []
623
+ if exported_payroll_records:
624
+ # Calculate totals for manager notification
625
+ total_amount = sum(p.total_amount for p in exported_payroll_records)
626
+ total_days_worked = sum(p.days_worked or 0 for p in exported_payroll_records)
627
+ total_tickets_closed = sum(p.tickets_closed or 0 for p in exported_payroll_records)
628
+
629
+ # Create manager notification
630
+ manager_notification = NotificationCreator.create(
631
+ db=db,
632
+ user_id=current_user.id,
633
+ title=f"Payroll Export Complete: {len(exported_payroll_records)} records",
634
+ message=(
635
+ f"Successfully exported payroll for period {from_date.isoformat()} to {to_date.isoformat()}.\n\n"
636
+ f"📊 Summary:\n"
637
+ f"• Workers: {len(exported_payroll_records)}\n"
638
+ f"• Total Amount: {total_amount:,.2f} KES\n"
639
+ f"• Total Days Worked: {total_days_worked}\n"
640
+ f"• Total Tickets Closed: {total_tickets_closed}\n"
641
+ f"• Warnings: {len(warnings)}"
642
+ ),
643
+ source_type="payroll",
644
+ source_id=None, # Bulk export, no single ID
645
+ notification_type="payroll_exported",
646
+ channel="in_app",
647
+ project_id=project_id,
648
+ metadata={
649
+ "payroll_count": len(exported_payroll_records),
650
+ "total_amount": float(total_amount),
651
+ "total_days_worked": total_days_worked,
652
+ "total_tickets_closed": total_tickets_closed,
653
+ "period_start": from_date.isoformat(),
654
+ "period_end": to_date.isoformat(),
655
+ "warning_count": len(warnings),
656
+ "warnings": warnings[:10] if warnings else [] # First 10 warnings
657
+ }
658
+ )
659
+ notifications_created.append(manager_notification)
660
+
661
+ # Create worker notifications
662
+ for payroll in exported_payroll_records:
663
+ worker = db.query(User).filter(User.id == payroll.user_id).first()
664
+ if worker:
665
+ # Build work breakdown message
666
+ work_details = []
667
+ if payroll.days_worked and payroll.days_worked > 0:
668
+ work_details.append(f"{payroll.days_worked} days worked")
669
+ if payroll.tickets_closed and payroll.tickets_closed > 0:
670
+ work_details.append(f"{payroll.tickets_closed} tickets closed")
671
+
672
+ work_summary = ", ".join(work_details) if work_details else "work completed"
673
+
674
+ # Build earnings breakdown
675
+ earnings_parts = []
676
+ if payroll.base_earnings and payroll.base_earnings > 0:
677
+ earnings_parts.append(f"Base: {payroll.base_earnings:,.2f} KES")
678
+ if payroll.bonus_amount and payroll.bonus_amount > 0:
679
+ earnings_parts.append(f"Bonus: {payroll.bonus_amount:,.2f} KES")
680
+ if payroll.deductions and payroll.deductions > 0:
681
+ earnings_parts.append(f"Deductions: -{payroll.deductions:,.2f} KES")
682
+
683
+ earnings_breakdown = "\n".join([f"• {part}" for part in earnings_parts])
684
+
685
+ worker_notification = NotificationCreator.create(
686
+ db=db,
687
+ user_id=worker.id,
688
+ title=f"💰 Payment Processed: {payroll.total_amount:,.2f} KES",
689
+ message=(
690
+ f"Your payment for {from_date.isoformat()} to {to_date.isoformat()} has been processed.\n\n"
691
+ f"📋 Work Summary:\n"
692
+ f"• {work_summary}\n\n"
693
+ f"💵 Earnings Breakdown:\n"
694
+ f"{earnings_breakdown}\n\n"
695
+ f"Total Payment: {payroll.total_amount:,.2f} KES"
696
+ ),
697
+ source_type="payroll",
698
+ source_id=payroll.id,
699
+ notification_type="payment",
700
+ channel="whatsapp", # Workers get WhatsApp notifications
701
+ project_id=project_id,
702
+ metadata={
703
+ "payroll_id": str(payroll.id),
704
+ "period_start": from_date.isoformat(),
705
+ "period_end": to_date.isoformat(),
706
+ "total_amount": float(payroll.total_amount),
707
+ "base_earnings": float(payroll.base_earnings) if payroll.base_earnings else 0,
708
+ "bonus_amount": float(payroll.bonus_amount) if payroll.bonus_amount else 0,
709
+ "deductions": float(payroll.deductions) if payroll.deductions else 0,
710
+ "days_worked": payroll.days_worked or 0,
711
+ "tickets_closed": payroll.tickets_closed or 0
712
+ }
713
+ )
714
+ notifications_created.append(worker_notification)
715
+
716
+ # Commit all notifications
717
+ db.commit()
718
+
719
+ logger.info(
720
+ f"Created {len(notifications_created)} notifications "
721
+ f"({len(exported_payroll_records)} workers + 1 manager)"
722
+ )
723
+
724
+ # Queue delivery for external channels (Tier 2 - Asynchronous)
725
+ NotificationDelivery.queue_bulk_delivery(
726
+ background_tasks=background_tasks,
727
+ notification_ids=[n.id for n in notifications_created]
728
+ )
729
+
730
+ # Return CSV file
731
+ return StreamingResponse(
732
+ iter([output.getvalue()]),
733
+ media_type="text/csv",
734
+ headers={
735
+ "Content-Disposition": f"attachment; filename={filename}",
736
+ "X-Warning-Count": str(len(warnings)),
737
+ "X-Exported-Count": str(len(csv_rows))
738
+ }
739
+ )
740
+
741
+ except HTTPException:
742
+ raise
743
+ except Exception as e:
744
+ logger.error(f"Error exporting payroll: {e}", exc_info=True)
745
+ raise HTTPException(
746
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
747
+ detail=f"Failed to export payroll: {str(e)}"
748
+ )
src/app/api/v1/projects.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Projects API Endpoints - Complete CRUD with nested resources
3
  """
4
- from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
5
  from sqlalchemy.orm import Session
6
  from sqlalchemy import or_
7
  from typing import Optional, List
@@ -701,6 +701,7 @@ async def update_project(
701
  async def complete_project_setup(
702
  project_id: UUID,
703
  setup_data: ProjectSetup,
 
704
  request: Request,
705
  current_user: User = Depends(get_current_active_user),
706
  db: Session = Depends(get_db)
@@ -728,7 +729,7 @@ async def complete_project_setup(
728
  - Project becomes ready for operations
729
  """
730
  try:
731
- result = ProjectService.complete_project_setup(db, project_id, setup_data, current_user)
732
 
733
  # Audit log
734
  AuditService.log_action(
@@ -1894,10 +1895,17 @@ async def create_project_role(
1894
  - compensation_type: flat_rate, commission, hourly, commission_plus_bonus, or custom
1895
 
1896
  **Compensation Requirements by Type:**
1897
- - flat_rate: Requires flat_rate_amount
1898
- - commission: Requires commission_percentage
1899
- - hourly: Requires hourly_rate
1900
- - commission_plus_bonus: Requires base_amount, commission_percentage
 
 
 
 
 
 
 
1901
  """
1902
  try:
1903
  role = ProjectService.create_project_role(db, project_id, role_data, current_user)
 
1
  """
2
  Projects API Endpoints - Complete CRUD with nested resources
3
  """
4
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, BackgroundTasks
5
  from sqlalchemy.orm import Session
6
  from sqlalchemy import or_
7
  from typing import Optional, List
 
701
  async def complete_project_setup(
702
  project_id: UUID,
703
  setup_data: ProjectSetup,
704
+ background_tasks: BackgroundTasks,
705
  request: Request,
706
  current_user: User = Depends(get_current_active_user),
707
  db: Session = Depends(get_db)
 
729
  - Project becomes ready for operations
730
  """
731
  try:
732
+ result = ProjectService.complete_project_setup(db, project_id, setup_data, current_user, background_tasks)
733
 
734
  # Audit log
735
  AuditService.log_action(
 
1895
  - compensation_type: flat_rate, commission, hourly, commission_plus_bonus, or custom
1896
 
1897
  **Compensation Requirements by Type:**
1898
+ - FIXED_RATE: Requires base_rate + rate_period (HOUR, DAY, WEEK, MONTH)
1899
+ - PER_UNIT: Requires per_unit_rate
1900
+ - COMMISSION: Requires commission_percentage
1901
+ - FIXED_PLUS_COMMISSION: Requires base_rate + rate_period + commission_percentage
1902
+
1903
+ **Examples:**
1904
+ - Kenya daily worker: FIXED_RATE, base_rate=1000, rate_period=DAY
1905
+ - USA hourly worker: FIXED_RATE, base_rate=25, rate_period=HOUR
1906
+ - Per-ticket tech: PER_UNIT, per_unit_rate=500
1907
+ - Sales agent: COMMISSION, commission_percentage=10
1908
+ - Hybrid: FIXED_PLUS_COMMISSION, base_rate=500, rate_period=DAY, commission_percentage=5
1909
  """
1910
  try:
1911
  role = ProjectService.create_project_role(db, project_id, role_data, current_user)
src/app/api/v1/sales_orders.py CHANGED
@@ -17,7 +17,7 @@ Endpoints:
17
  - DELETE /sales-orders/{id} - Soft delete
18
  - GET /sales-orders/csv-template - Download CSV template
19
  """
20
- from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form
21
  from fastapi.responses import StreamingResponse
22
  from sqlalchemy.orm import Session
23
  from typing import Optional
@@ -32,6 +32,7 @@ from app.api.deps import get_db, get_current_user, get_current_active_user
32
  from app.models.user import User
33
  from app.models.enums import AppRole
34
  from app.services.sales_order_service import SalesOrderService
 
35
  from app.schemas.sales_order import *
36
  from app.schemas.filters import SalesOrderFilters
37
  from app.utils.phone_utils import normalize_kenyan_phone
@@ -300,6 +301,7 @@ async def import_sales_orders_from_csv(
300
  file: UploadFile = File(...),
301
  project_id: str = Form(...),
302
  project_region_id: Optional[str] = Form(None),
 
303
  db: Session = Depends(get_db),
304
  current_user: User = Depends(get_current_user)
305
  ):
@@ -413,7 +415,7 @@ async def import_sales_orders_from_csv(
413
  )
414
 
415
  # Bulk create
416
- result = SalesOrderService.bulk_create_from_csv(db, import_data, current_user)
417
 
418
  # Add parse errors to row_results
419
  from app.schemas.sales_order import SalesOrderImportRowResult
@@ -868,6 +870,7 @@ async def promote_sales_order_to_ticket(
868
  @router.post("/bulk-promote", response_model=SalesOrderBulkPromoteResult)
869
  async def bulk_promote_sales_orders_to_tickets(
870
  data: SalesOrderBulkPromote,
 
871
  db: Session = Depends(get_db),
872
  current_user: User = Depends(get_current_user)
873
  ):
@@ -877,9 +880,9 @@ async def bulk_promote_sales_orders_to_tickets(
877
  Authorization: Checked by service for each order
878
  """
879
  try:
880
- result = SalesOrderService.bulk_promote_to_tickets(db, data, current_user)
881
  logger.info(f"Bulk promote: {result.successful} successful, {result.failed} failed")
882
-
883
  return result
884
  except HTTPException:
885
  raise
 
17
  - DELETE /sales-orders/{id} - Soft delete
18
  - GET /sales-orders/csv-template - Download CSV template
19
  """
20
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form, BackgroundTasks
21
  from fastapi.responses import StreamingResponse
22
  from sqlalchemy.orm import Session
23
  from typing import Optional
 
32
  from app.models.user import User
33
  from app.models.enums import AppRole
34
  from app.services.sales_order_service import SalesOrderService
35
+ from app.services.notification_delivery import NotificationDelivery
36
  from app.schemas.sales_order import *
37
  from app.schemas.filters import SalesOrderFilters
38
  from app.utils.phone_utils import normalize_kenyan_phone
 
301
  file: UploadFile = File(...),
302
  project_id: str = Form(...),
303
  project_region_id: Optional[str] = Form(None),
304
+ background_tasks: BackgroundTasks,
305
  db: Session = Depends(get_db),
306
  current_user: User = Depends(get_current_user)
307
  ):
 
415
  )
416
 
417
  # Bulk create
418
+ result = SalesOrderService.bulk_create_from_csv(db, import_data, current_user, background_tasks)
419
 
420
  # Add parse errors to row_results
421
  from app.schemas.sales_order import SalesOrderImportRowResult
 
870
  @router.post("/bulk-promote", response_model=SalesOrderBulkPromoteResult)
871
  async def bulk_promote_sales_orders_to_tickets(
872
  data: SalesOrderBulkPromote,
873
+ background_tasks: BackgroundTasks,
874
  db: Session = Depends(get_db),
875
  current_user: User = Depends(get_current_user)
876
  ):
 
880
  Authorization: Checked by service for each order
881
  """
882
  try:
883
+ result = SalesOrderService.bulk_promote_to_tickets(db, data, current_user, background_tasks)
884
  logger.info(f"Bulk promote: {result.successful} successful, {result.failed} failed")
885
+
886
  return result
887
  except HTTPException:
888
  raise
src/app/api/v1/tasks.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Tasks API Endpoints - Infrastructure project task management
3
  """
4
- from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
5
  from sqlalchemy.orm import Session
6
  from typing import Optional, List
7
  from uuid import UUID
@@ -83,6 +83,7 @@ def parse_task_filters(
83
  async def create_task(
84
  data: TaskCreate,
85
  request: Request,
 
86
  current_user: User = Depends(get_current_active_user),
87
  db: Session = Depends(get_db)
88
  ):
@@ -127,7 +128,7 @@ async def create_task(
127
  - Computed properties (is_completed, is_overdue, has_location, duration_days)
128
  """
129
  try:
130
- task = TaskService.create_task(db, data, current_user)
131
 
132
  # Log audit trail
133
  await AuditService.log_action(
@@ -654,6 +655,7 @@ async def complete_task(
654
  task_id: UUID,
655
  data: TaskComplete,
656
  request: Request,
 
657
  current_user: User = Depends(get_current_active_user),
658
  db: Session = Depends(get_db)
659
  ):
@@ -665,7 +667,7 @@ async def complete_task(
665
  Auto-sets started_at if not already set.
666
  """
667
  try:
668
- task = TaskService.complete_task(db, task_id, data, current_user)
669
 
670
  # Log audit trail
671
  await AuditService.log_action(
@@ -709,6 +711,7 @@ async def cancel_task(
709
  task_id: UUID,
710
  data: TaskCancel,
711
  request: Request,
 
712
  current_user: User = Depends(get_current_active_user),
713
  db: Session = Depends(get_db)
714
  ):
@@ -720,7 +723,7 @@ async def cancel_task(
720
  Requires cancellation reason.
721
  """
722
  try:
723
- task = TaskService.cancel_task(db, task_id, data, current_user)
724
 
725
  # Log audit trail
726
  await AuditService.log_action(
 
1
  """
2
  Tasks API Endpoints - Infrastructure project task management
3
  """
4
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, BackgroundTasks
5
  from sqlalchemy.orm import Session
6
  from typing import Optional, List
7
  from uuid import UUID
 
83
  async def create_task(
84
  data: TaskCreate,
85
  request: Request,
86
+ background_tasks: BackgroundTasks,
87
  current_user: User = Depends(get_current_active_user),
88
  db: Session = Depends(get_db)
89
  ):
 
128
  - Computed properties (is_completed, is_overdue, has_location, duration_days)
129
  """
130
  try:
131
+ task = TaskService.create_task(db, data, current_user, background_tasks)
132
 
133
  # Log audit trail
134
  await AuditService.log_action(
 
655
  task_id: UUID,
656
  data: TaskComplete,
657
  request: Request,
658
+ background_tasks: BackgroundTasks,
659
  current_user: User = Depends(get_current_active_user),
660
  db: Session = Depends(get_db)
661
  ):
 
667
  Auto-sets started_at if not already set.
668
  """
669
  try:
670
+ task = TaskService.complete_task(db, task_id, data, current_user, background_tasks)
671
 
672
  # Log audit trail
673
  await AuditService.log_action(
 
711
  task_id: UUID,
712
  data: TaskCancel,
713
  request: Request,
714
+ background_tasks: BackgroundTasks,
715
  current_user: User = Depends(get_current_active_user),
716
  db: Session = Depends(get_db)
717
  ):
 
723
  Requires cancellation reason.
724
  """
725
  try:
726
+ task = TaskService.cancel_task(db, task_id, data, current_user, background_tasks)
727
 
728
  # Log audit trail
729
  await AuditService.log_action(
src/app/api/v1/ticket_assignments.py CHANGED
@@ -8,7 +8,7 @@ Handles:
8
  - Queue management
9
  """
10
 
11
- from fastapi import APIRouter, Depends, HTTPException, status, Query
12
  from sqlalchemy.orm import Session
13
  from typing import List, Optional
14
  from uuid import UUID
@@ -20,6 +20,8 @@ from app.api.deps import get_db, get_current_user
20
  from app.models.user import User
21
  from app.models.enums import AppRole
22
  from app.services.ticket_assignment_service import TicketAssignmentService
 
 
23
  from app.schemas.ticket_assignment import (
24
  TicketAssignCreate,
25
  TicketAssignTeamCreate,
@@ -511,11 +513,83 @@ def mark_customer_unavailable(
511
  def drop_assignment(
512
  assignment_id: UUID,
513
  data: AssignmentDrop,
 
514
  db: Session = Depends(get_db),
515
  current_user: User = Depends(get_current_user)
516
  ):
517
  service = TicketAssignmentService(db)
518
- return service.drop_assignment(assignment_id, current_user.id, data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
 
520
 
521
  @router.post(
 
8
  - Queue management
9
  """
10
 
11
+ from fastapi import APIRouter, Depends, HTTPException, status, Query, BackgroundTasks
12
  from sqlalchemy.orm import Session
13
  from typing import List, Optional
14
  from uuid import UUID
 
20
  from app.models.user import User
21
  from app.models.enums import AppRole
22
  from app.services.ticket_assignment_service import TicketAssignmentService
23
+ from app.services.notification_creator import NotificationCreator
24
+ from app.services.notification_delivery import NotificationDelivery
25
  from app.schemas.ticket_assignment import (
26
  TicketAssignCreate,
27
  TicketAssignTeamCreate,
 
513
  def drop_assignment(
514
  assignment_id: UUID,
515
  data: AssignmentDrop,
516
+ background_tasks: BackgroundTasks,
517
  db: Session = Depends(get_db),
518
  current_user: User = Depends(get_current_user)
519
  ):
520
  service = TicketAssignmentService(db)
521
+ result = service.drop_assignment(assignment_id, current_user.id, data)
522
+
523
+ # Create notifications for managers (Tier 1 - Synchronous)
524
+ try:
525
+ from app.models.ticket_assignment import TicketAssignment
526
+ from app.models.ticket import Ticket
527
+
528
+ # Get fresh assignment and ticket data
529
+ assignment = db.query(TicketAssignment).filter(TicketAssignment.id == assignment_id).first()
530
+ if assignment:
531
+ ticket = db.query(Ticket).filter(Ticket.id == assignment.ticket_id).first()
532
+ if ticket:
533
+ # Build drop reason message
534
+ drop_type_labels = {
535
+ "equipment_issue": "Equipment Issue",
536
+ "site_access_denied": "Site Access Denied",
537
+ "customer_unavailable": "Customer Unavailable",
538
+ "safety_concern": "Safety Concern",
539
+ "other": "Other"
540
+ }
541
+ drop_type_label = drop_type_labels.get(data.drop_type, data.drop_type)
542
+
543
+ # Notify all managers and dispatchers in the project
544
+ notifications = NotificationCreator.notify_project_team(
545
+ db=db,
546
+ project_id=ticket.project_id,
547
+ title="⚠️ Ticket Dropped - Action Required",
548
+ message=(
549
+ f"{current_user.name} dropped ticket: {ticket.ticket_name or ticket.ticket_type}\n\n"
550
+ f"🔴 Reason: {drop_type_label}\n"
551
+ f"💬 Details: {data.reason or 'No details provided'}\n\n"
552
+ f"⚡ This ticket is now in PENDING REVIEW and needs your attention."
553
+ ),
554
+ source_type="ticket",
555
+ source_id=ticket.id,
556
+ notification_type="ticket_dropped",
557
+ roles=[AppRole.PROJECT_MANAGER, AppRole.DISPATCHER],
558
+ channel="in_app",
559
+ exclude_user_ids=[current_user.id], # Don't notify the agent who dropped
560
+ metadata={
561
+ "ticket_id": str(ticket.id),
562
+ "ticket_name": ticket.ticket_name,
563
+ "ticket_type": ticket.ticket_type,
564
+ "assignment_id": str(assignment.id),
565
+ "dropped_by_user_id": str(current_user.id),
566
+ "dropped_by_name": current_user.name,
567
+ "drop_type": data.drop_type,
568
+ "reason": data.reason,
569
+ "requires_action": True,
570
+ "priority": "high",
571
+ "action_url": f"/tickets/{ticket.id}"
572
+ }
573
+ )
574
+
575
+ # Commit notifications
576
+ db.commit()
577
+
578
+ logger.info(
579
+ f"Created {len(notifications)} drop notifications for ticket {ticket.id}"
580
+ )
581
+
582
+ # Queue delivery (Tier 2 - Asynchronous)
583
+ NotificationDelivery.queue_bulk_delivery(
584
+ background_tasks=background_tasks,
585
+ notification_ids=[n.id for n in notifications]
586
+ )
587
+
588
+ except Exception as e:
589
+ # Don't fail the request if notification fails
590
+ logger.warning(f"Failed to create drop notification: {str(e)}", exc_info=True)
591
+
592
+ return result
593
 
594
 
595
  @router.post(
src/app/api/v1/tickets.py CHANGED
@@ -13,7 +13,7 @@ Authorization:
13
  - dispatcher: Their contractor's projects
14
  - client_admin: View only
15
  """
16
- from fastapi import APIRouter, Depends, status, Query
17
  from sqlalchemy.orm import Session
18
  from typing import List, Optional
19
  from uuid import UUID
@@ -981,6 +981,7 @@ def reschedule_ticket(
981
  def cancel_ticket(
982
  ticket_id: UUID,
983
  data: TicketCancel,
 
984
  db: Session = Depends(get_db),
985
  current_user: User = Depends(get_current_user)
986
  ):
@@ -1008,7 +1009,8 @@ def cancel_ticket(
1008
  db=db,
1009
  ticket_id=ticket_id,
1010
  data=data,
1011
- current_user=current_user
 
1012
  )
1013
 
1014
  return TicketResponse.from_orm(ticket)
@@ -1022,6 +1024,7 @@ def cancel_ticket(
1022
  def reopen_ticket(
1023
  ticket_id: UUID,
1024
  data: TicketReopen,
 
1025
  db: Session = Depends(get_db),
1026
  current_user: User = Depends(get_current_user)
1027
  ):
@@ -1052,7 +1055,8 @@ def reopen_ticket(
1052
  db=db,
1053
  ticket_id=ticket_id,
1054
  data=data,
1055
- current_user=current_user
 
1056
  )
1057
 
1058
  return TicketResponse.from_orm(ticket)
 
13
  - dispatcher: Their contractor's projects
14
  - client_admin: View only
15
  """
16
+ from fastapi import APIRouter, Depends, status, Query, BackgroundTasks
17
  from sqlalchemy.orm import Session
18
  from typing import List, Optional
19
  from uuid import UUID
 
981
  def cancel_ticket(
982
  ticket_id: UUID,
983
  data: TicketCancel,
984
+ background_tasks: BackgroundTasks,
985
  db: Session = Depends(get_db),
986
  current_user: User = Depends(get_current_user)
987
  ):
 
1009
  db=db,
1010
  ticket_id=ticket_id,
1011
  data=data,
1012
+ current_user=current_user,
1013
+ background_tasks=background_tasks
1014
  )
1015
 
1016
  return TicketResponse.from_orm(ticket)
 
1024
  def reopen_ticket(
1025
  ticket_id: UUID,
1026
  data: TicketReopen,
1027
+ background_tasks: BackgroundTasks,
1028
  db: Session = Depends(get_db),
1029
  current_user: User = Depends(get_current_user)
1030
  ):
 
1055
  db=db,
1056
  ticket_id=ticket_id,
1057
  data=data,
1058
+ current_user=current_user,
1059
+ background_tasks=background_tasks
1060
  )
1061
 
1062
  return TicketResponse.from_orm(ticket)
src/app/models/enums.py CHANGED
@@ -50,18 +50,30 @@ class ProjectStatus(str, enum.Enum):
50
 
51
 
52
  class CompensationType(str, enum.Enum):
53
- """Compensation models"""
54
- # NEW - Simple cash-based (recommended for Kenyan context)
55
- PER_DAY = "per_day" # Fixed daily rate (e.g., 1000 KES/day)
56
- PER_WEEK = "per_week" # Fixed weekly rate (e.g., 7000 KES/week)
57
- PER_TICKET = "per_ticket" # Fixed per ticket (e.g., 500 KES/ticket)
58
-
59
- # OLD - Keep for backward compatibility
60
- FLAT_RATE = "flat_rate" # Legacy: weekly flat rate
61
- COMMISSION = "commission" # Percentage-based earnings
62
- HYBRID = "hybrid" # Mixed compensation
63
- HOURLY = "hourly" # Hourly rate
64
- CUSTOM = "custom" # Fully flexible
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
 
67
  class TimesheetStatus(str, enum.Enum):
 
50
 
51
 
52
  class CompensationType(str, enum.Enum):
53
+ """
54
+ Compensation models - Simplified global structure
55
+
56
+ Four types cover all compensation scenarios worldwide:
57
+ - FIXED_RATE: Time-based pay (hourly, daily, weekly, monthly)
58
+ - PER_UNIT: Work-based pay (per-ticket, per-job, per-item)
59
+ - COMMISSION: Percentage-based pay (sales commission)
60
+ - FIXED_PLUS_COMMISSION: Hybrid (base + commission)
61
+ """
62
+ FIXED_RATE = "FIXED_RATE" # Time-based: hourly, daily, weekly, monthly
63
+ PER_UNIT = "PER_UNIT" # Work-based: per-ticket, per-job
64
+ COMMISSION = "COMMISSION" # Percentage-based: sales commission
65
+ FIXED_PLUS_COMMISSION = "FIXED_PLUS_COMMISSION" # Hybrid: base + commission
66
+
67
+
68
+ class RatePeriod(str, enum.Enum):
69
+ """
70
+ Rate period for FIXED_RATE compensation
71
+ Defines the time unit for base_rate
72
+ """
73
+ HOUR = "HOUR" # Hourly rate (e.g., $25/hour in USA)
74
+ DAY = "DAY" # Daily rate (e.g., 1000 KES/day in Kenya)
75
+ WEEK = "WEEK" # Weekly rate (e.g., 7000 KES/week)
76
+ MONTH = "MONTH" # Monthly salary (e.g., 50000 KES/month)
77
 
78
 
79
  class TimesheetStatus(str, enum.Enum):
src/app/models/project.py CHANGED
@@ -235,80 +235,72 @@ class ProjectRegion(BaseModel):
235
  class ProjectRole(BaseModel):
236
  """
237
  ProjectRole model - Defines roles and compensation within a project
238
- Maps to 'project_roles' table in docs/schema/schema.sql
239
-
240
- Purpose: Define job functions and pay structures for project workers
241
- Used primarily for casual workers paid weekly
242
- Examples: 'Technician', 'Supervisor', 'Driver', 'Helper'
 
 
 
 
 
 
 
 
243
  """
244
  __tablename__ = "project_roles"
245
-
246
  # Relationships
247
  project_id = Column(UUID(as_uuid=True), ForeignKey('projects.id', ondelete='CASCADE'), nullable=False)
248
-
249
  # Role Definition
250
  role_name = Column(Text, nullable=False) # e.g., 'Technician', 'Supervisor'
251
  description = Column(Text, nullable=True)
252
-
253
  # Compensation Structure
254
  compensation_type = Column(String(50), nullable=False) # CompensationType enum in DB
255
-
256
- # NEW - Simple cash-based fields (recommended)
257
- daily_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Fixed daily rate (e.g., 1000 KES/day)
258
- weekly_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Fixed weekly rate (e.g., 7000 KES/week)
259
- per_ticket_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Fixed per ticket (e.g., 500 KES/ticket)
260
-
261
- # OLD - Keep for backward compatibility
262
- flat_rate_amount = Column(Numeric(precision=12, scale=2), nullable=True) # Legacy: weekly flat rate
263
- commission_percentage = Column(Numeric(precision=5, scale=2), nullable=True) # Commission per ticket
264
- base_amount = Column(Numeric(precision=12, scale=2), nullable=True) # Base amount per ticket
265
- bonus_percentage = Column(Numeric(precision=5, scale=2), nullable=True) # Bonus percentage
266
- hourly_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Hourly rate
267
-
268
  # Status
269
  is_active = Column(Boolean, default=True, nullable=False)
270
-
271
  # Relationships
272
  project = relationship("Project", back_populates="roles")
273
-
274
  # Table constraints
275
  __table_args__ = (
276
- # New simple fields
277
- CheckConstraint(
278
- 'daily_rate IS NULL OR daily_rate >= 0',
279
- name='chk_positive_daily_rate'
280
- ),
281
  CheckConstraint(
282
- 'weekly_rate IS NULL OR weekly_rate >= 0',
283
- name='chk_positive_weekly_rate'
284
  ),
 
285
  CheckConstraint(
286
- 'per_ticket_rate IS NULL OR per_ticket_rate >= 0',
287
- name='chk_positive_per_ticket_rate'
288
  ),
289
- # Old fields (backward compatibility)
290
  CheckConstraint(
291
- 'flat_rate_amount IS NULL OR flat_rate_amount >= 0',
292
- name='chk_positive_flat_rate'
293
  ),
 
294
  CheckConstraint(
295
- 'commission_percentage IS NULL OR (commission_percentage >= 0 AND commission_percentage <= 100)',
296
- name='chk_valid_commission'
297
- ),
298
- CheckConstraint(
299
- 'base_amount IS NULL OR base_amount >= 0',
300
- name='chk_positive_base_amount'
301
- ),
302
- CheckConstraint(
303
- 'bonus_percentage IS NULL OR (bonus_percentage >= 0 AND bonus_percentage <= 100)',
304
- name='chk_valid_bonus'
305
- ),
306
- CheckConstraint(
307
- 'hourly_rate IS NULL OR hourly_rate >= 0',
308
- name='chk_positive_hourly_rate'
309
  ),
310
  )
311
-
312
  def __repr__(self):
313
  return f"<ProjectRole(name='{self.role_name}', type='{self.compensation_type}')>"
314
 
 
235
  class ProjectRole(BaseModel):
236
  """
237
  ProjectRole model - Defines roles and compensation within a project
238
+
239
+ Simplified global compensation structure with 4 types:
240
+ - FIXED_RATE: Time-based pay (hourly, daily, weekly, monthly)
241
+ - PER_UNIT: Work-based pay (per-ticket, per-job)
242
+ - COMMISSION: Percentage-based pay (sales commission)
243
+ - FIXED_PLUS_COMMISSION: Hybrid (base + commission)
244
+
245
+ Examples:
246
+ - Kenya daily worker: FIXED_RATE, base_rate=1000, rate_period=DAY
247
+ - USA hourly worker: FIXED_RATE, base_rate=25, rate_period=HOUR
248
+ - Per-ticket tech: PER_UNIT, per_unit_rate=500
249
+ - Sales agent: COMMISSION, commission_percentage=10
250
+ - Hybrid: FIXED_PLUS_COMMISSION, base_rate=500, rate_period=DAY, commission_percentage=5
251
  """
252
  __tablename__ = "project_roles"
253
+
254
  # Relationships
255
  project_id = Column(UUID(as_uuid=True), ForeignKey('projects.id', ondelete='CASCADE'), nullable=False)
256
+
257
  # Role Definition
258
  role_name = Column(Text, nullable=False) # e.g., 'Technician', 'Supervisor'
259
  description = Column(Text, nullable=True)
260
+
261
  # Compensation Structure
262
  compensation_type = Column(String(50), nullable=False) # CompensationType enum in DB
263
+
264
+ # For FIXED_RATE and FIXED_PLUS_COMMISSION
265
+ base_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Base rate amount
266
+ rate_period = Column(String(10), nullable=True) # RatePeriod enum: HOUR, DAY, WEEK, MONTH
267
+
268
+ # For PER_UNIT
269
+ per_unit_rate = Column(Numeric(precision=12, scale=2), nullable=True) # Per-ticket/job rate
270
+
271
+ # For COMMISSION and FIXED_PLUS_COMMISSION
272
+ commission_percentage = Column(Numeric(precision=5, scale=2), nullable=True) # Commission %
273
+
 
 
274
  # Status
275
  is_active = Column(Boolean, default=True, nullable=False)
276
+
277
  # Relationships
278
  project = relationship("Project", back_populates="roles")
279
+
280
  # Table constraints
281
  __table_args__ = (
282
+ # FIXED_RATE requires base_rate and rate_period
 
 
 
 
283
  CheckConstraint(
284
+ "compensation_type != 'FIXED_RATE' OR (base_rate IS NOT NULL AND base_rate >= 0 AND rate_period IS NOT NULL)",
285
+ name='chk_fixed_rate_fields'
286
  ),
287
+ # PER_UNIT requires per_unit_rate
288
  CheckConstraint(
289
+ "compensation_type != 'PER_UNIT' OR (per_unit_rate IS NOT NULL AND per_unit_rate >= 0)",
290
+ name='chk_per_unit_fields'
291
  ),
292
+ # COMMISSION requires commission_percentage
293
  CheckConstraint(
294
+ "compensation_type != 'COMMISSION' OR (commission_percentage IS NOT NULL AND commission_percentage >= 0 AND commission_percentage <= 100)",
295
+ name='chk_commission_fields'
296
  ),
297
+ # FIXED_PLUS_COMMISSION requires base_rate, rate_period, and commission_percentage
298
  CheckConstraint(
299
+ "compensation_type != 'FIXED_PLUS_COMMISSION' OR (base_rate IS NOT NULL AND base_rate >= 0 AND rate_period IS NOT NULL AND commission_percentage IS NOT NULL AND commission_percentage >= 0 AND commission_percentage <= 100)",
300
+ name='chk_fixed_plus_commission_fields'
 
 
 
 
 
 
 
 
 
 
 
 
301
  ),
302
  )
303
+
304
  def __repr__(self):
305
  return f"<ProjectRole(name='{self.role_name}', type='{self.compensation_type}')>"
306
 
src/app/models/ticket.py CHANGED
@@ -98,7 +98,11 @@ class Ticket(BaseModel):
98
  # Metadata
99
  notes = Column(Text, nullable=True)
100
  additional_metadata = Column(JSONB, nullable=False, default=dict, server_default='{}')
101
-
 
 
 
 
102
  # Concurrency Control (Optimistic Locking)
103
  version = Column(Integer, nullable=False, default=1)
104
 
 
98
  # Metadata
99
  notes = Column(Text, nullable=True)
100
  additional_metadata = Column(JSONB, nullable=False, default=dict, server_default='{}')
101
+
102
+ # Timesheet Sync Tracking
103
+ timesheet_synced = Column(Boolean, default=False, nullable=False) # Has this been synced to timesheets?
104
+ timesheet_synced_at = Column(DateTime(timezone=True), nullable=True) # When was it synced?
105
+
106
  # Concurrency Control (Optimistic Locking)
107
  version = Column(Integer, nullable=False, default=1)
108
 
src/app/models/ticket_assignment.py CHANGED
@@ -77,6 +77,10 @@ class TicketAssignment(BaseModel):
77
  reason = Column(Text, nullable=True) # Why assigned/dropped/rejected
78
  notes = Column(Text, nullable=True)
79
 
 
 
 
 
80
  # Relationships
81
  ticket = relationship("Ticket", back_populates="assignments", lazy="select")
82
  user = relationship("User", foreign_keys=[user_id], lazy="select")
 
77
  reason = Column(Text, nullable=True) # Why assigned/dropped/rejected
78
  notes = Column(Text, nullable=True)
79
 
80
+ # Timesheet Sync Tracking
81
+ timesheet_synced = Column(Boolean, default=False, nullable=False) # Has this been synced to timesheets?
82
+ timesheet_synced_at = Column(DateTime(timezone=True), nullable=True) # When was it synced?
83
+
84
  # Relationships
85
  ticket = relationship("Ticket", back_populates="assignments", lazy="select")
86
  user = relationship("User", foreign_keys=[user_id], lazy="select")
src/app/models/ticket_expense.py CHANGED
@@ -122,7 +122,11 @@ class TicketExpense(BaseModel):
122
  default={},
123
  server_default="{}"
124
  ) # Can store split details: {"split_with": ["user-id-1", "user-id-2"], "split_amount": 500}
125
-
 
 
 
 
126
  # Timestamps inherited from BaseModel (created_at, updated_at, deleted_at)
127
 
128
  # Relationships
 
122
  default={},
123
  server_default="{}"
124
  ) # Can store split details: {"split_with": ["user-id-1", "user-id-2"], "split_amount": 500}
125
+
126
+ # Timesheet Sync Tracking
127
+ timesheet_synced = Column(Boolean, default=False, nullable=False) # Has this been synced to timesheets?
128
+ timesheet_synced_at = Column(TIMESTAMP(timezone=True), nullable=True) # When was it synced?
129
+
130
  # Timestamps inherited from BaseModel (created_at, updated_at, deleted_at)
131
 
132
  # Relationships
src/app/models/timesheet.py CHANGED
@@ -53,17 +53,27 @@ class Timesheet(BaseModel):
53
  leave_reason = Column(Text, nullable=True)
54
  leave_approved_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
55
 
56
- # Daily Ticket Metrics (calculated by daily cron job)
57
  tickets_assigned = Column(Integer, default=0, nullable=False) # Number of tickets assigned this day
58
  tickets_completed = Column(Integer, default=0, nullable=False) # Number of tickets completed this day
59
  tickets_rescheduled = Column(Integer, default=0, nullable=False) # Number of tickets rescheduled this day
60
  tickets_cancelled = Column(Integer, default=0, nullable=False) # Number of tickets cancelled this day
61
  tickets_rejected = Column(Integer, default=0, nullable=False) # Number of tickets rejected this day
 
 
 
 
 
 
 
62
 
63
  # Payroll Linkage (tracks if this timesheet has been used in payroll calculation)
64
  is_payroll_generated = Column(Boolean, default=False, nullable=False)
65
  payroll_id = Column(UUID(as_uuid=True), ForeignKey("user_payroll.id", ondelete="SET NULL"), nullable=True, index=True)
66
 
 
 
 
67
  # Metadata
68
  notes = Column(Text, nullable=True)
69
  additional_metadata = Column(JSONB, default={}, nullable=False)
@@ -76,7 +86,7 @@ class Timesheet(BaseModel):
76
 
77
  # Constraints
78
  __table_args__ = (
79
- UniqueConstraint('user_id', 'work_date', name='uq_timesheets_user_date'),
80
  CheckConstraint('hours_worked IS NULL OR (hours_worked >= 0 AND hours_worked <= 24)', name='chk_valid_hours'),
81
  CheckConstraint('check_out_time IS NULL OR check_in_time IS NULL OR check_out_time >= check_in_time', name='chk_check_times'),
82
  )
 
53
  leave_reason = Column(Text, nullable=True)
54
  leave_approved_by_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
55
 
56
+ # Daily Ticket Metrics (updated in real-time)
57
  tickets_assigned = Column(Integer, default=0, nullable=False) # Number of tickets assigned this day
58
  tickets_completed = Column(Integer, default=0, nullable=False) # Number of tickets completed this day
59
  tickets_rescheduled = Column(Integer, default=0, nullable=False) # Number of tickets rescheduled this day
60
  tickets_cancelled = Column(Integer, default=0, nullable=False) # Number of tickets cancelled this day
61
  tickets_rejected = Column(Integer, default=0, nullable=False) # Number of tickets rejected this day
62
+
63
+ # Daily Expense Metrics (updated in real-time)
64
+ total_expenses = Column(Numeric(12, 2), default=0, nullable=False) # Total expense amount claimed
65
+ approved_expenses = Column(Numeric(12, 2), default=0, nullable=False) # Approved expense amount
66
+ pending_expenses = Column(Numeric(12, 2), default=0, nullable=False) # Pending approval amount
67
+ rejected_expenses = Column(Numeric(12, 2), default=0, nullable=False) # Rejected expense amount
68
+ expense_claims_count = Column(Integer, default=0, nullable=False) # Number of expense claims submitted
69
 
70
  # Payroll Linkage (tracks if this timesheet has been used in payroll calculation)
71
  is_payroll_generated = Column(Boolean, default=False, nullable=False)
72
  payroll_id = Column(UUID(as_uuid=True), ForeignKey("user_payroll.id", ondelete="SET NULL"), nullable=True, index=True)
73
 
74
+ # Optimistic Locking (prevents concurrent update conflicts)
75
+ version = Column(Integer, default=1, nullable=False) # Auto-incremented by trigger on update
76
+
77
  # Metadata
78
  notes = Column(Text, nullable=True)
79
  additional_metadata = Column(JSONB, default={}, nullable=False)
 
86
 
87
  # Constraints
88
  __table_args__ = (
89
+ UniqueConstraint('user_id', 'project_id', 'work_date', name='uq_timesheets_user_project_date'),
90
  CheckConstraint('hours_worked IS NULL OR (hours_worked >= 0 AND hours_worked <= 24)', name='chk_valid_hours'),
91
  CheckConstraint('check_out_time IS NULL OR check_in_time IS NULL OR check_out_time >= check_in_time', name='chk_check_times'),
92
  )
src/app/models/user_payroll.py CHANGED
@@ -13,26 +13,32 @@ from app.models.base import BaseModel
13
  class UserPayroll(BaseModel):
14
  """
15
  User Payroll (Worker Compensation & Payment Tracking)
16
-
17
  Tracks all work done by users and their compensation.
18
- Used primarily for casual workers paid weekly.
19
- Tracks tickets closed, bonuses, flat rates, and payment status.
20
-
21
  Key Features:
22
- - Weekly pay periods (Monday-Sunday)
23
- - Aggregated work summary (tickets, hours, days)
24
- - Compensation breakdown (flat rate, ticket earnings, bonuses, deductions)
25
  - Payment tracking with reference numbers
26
  - Optimistic locking for concurrent updates
27
-
28
  Business Rules:
29
  - One payroll record per user per project per pay period
30
  - period_end_date >= period_start_date
31
  - All amounts must be non-negative
32
- - total_amount = flat_rate + ticket_earnings + bonus - deductions
33
  - Can only be paid once (is_paid flag)
34
  - Version column prevents race conditions during recalculation
35
-
 
 
 
 
 
 
 
36
  Generation:
37
  - Automatically generated weekly (Monday morning for previous Mon-Sun)
38
  - Recalculated if timesheets are corrected
@@ -50,15 +56,13 @@ class UserPayroll(BaseModel):
50
  period_start_date = Column(Date, nullable=False, index=True)
51
  period_end_date = Column(Date, nullable=False, index=True)
52
 
53
- # Work Summary (aggregated from timesheets and ticket_assignments)
54
  tickets_closed = Column(Integer, default=0, nullable=False)
55
- hours_worked = Column(Numeric(10, 2), default=0, nullable=False)
56
  days_worked = Column(Integer, default=0, nullable=False)
57
-
58
  # Compensation Breakdown
59
- flat_rate_amount = Column(Numeric(12, 2), default=0, nullable=False) # Weekly flat rate
60
- ticket_earnings = Column(Numeric(12, 2), default=0, nullable=False) # Earnings from tickets
61
- bonus_amount = Column(Numeric(12, 2), default=0, nullable=False) # Performance bonuses
62
  deductions = Column(Numeric(12, 2), default=0, nullable=False) # Deductions (advances, etc.)
63
  total_amount = Column(Numeric(12, 2), nullable=False) # Total compensation
64
 
@@ -87,14 +91,12 @@ class UserPayroll(BaseModel):
87
 
88
  # Constraints
89
  __table_args__ = (
90
- UniqueConstraint('user_id', 'project_id', 'period_start_date', 'period_end_date',
91
  name='uq_user_payroll_period'),
92
  CheckConstraint('period_end_date >= period_start_date', name='chk_valid_period'),
93
  CheckConstraint('tickets_closed >= 0', name='chk_positive_tickets'),
94
- CheckConstraint('hours_worked >= 0', name='chk_positive_hours'),
95
  CheckConstraint('days_worked >= 0', name='chk_positive_days'),
96
- CheckConstraint('flat_rate_amount >= 0', name='chk_positive_flat_rate'),
97
- CheckConstraint('ticket_earnings >= 0', name='chk_positive_ticket_earnings'),
98
  CheckConstraint('bonus_amount >= 0', name='chk_positive_bonus'),
99
  CheckConstraint('deductions >= 0', name='chk_positive_deductions'),
100
  CheckConstraint('total_amount >= 0', name='chk_positive_total'),
@@ -117,7 +119,7 @@ class UserPayroll(BaseModel):
117
  @property
118
  def net_earnings(self) -> Decimal:
119
  """Calculate net earnings (before deductions)"""
120
- return Decimal(self.flat_rate_amount or 0) + Decimal(self.ticket_earnings or 0) + Decimal(self.bonus_amount or 0)
121
 
122
  def calculate_total(self) -> Decimal:
123
  """Calculate total compensation amount"""
@@ -145,10 +147,8 @@ class UserPayroll(BaseModel):
145
  def recalculate_from_data(
146
  self,
147
  tickets_closed: int,
148
- hours_worked: Decimal,
149
  days_worked: int,
150
- flat_rate: Decimal,
151
- ticket_earnings: Decimal,
152
  bonus: Decimal = Decimal('0'),
153
  deductions: Decimal = Decimal('0'),
154
  calculation_notes: Optional[str] = None
@@ -156,16 +156,14 @@ class UserPayroll(BaseModel):
156
  """Recalculate payroll from fresh data (used when timesheets are corrected)"""
157
  if self.is_paid:
158
  raise ValueError("Cannot recalculate paid payroll")
159
-
160
  self.tickets_closed = tickets_closed
161
- self.hours_worked = hours_worked
162
  self.days_worked = days_worked
163
- self.flat_rate_amount = flat_rate
164
- self.ticket_earnings = ticket_earnings
165
  self.bonus_amount = bonus
166
  self.deductions = deductions
167
  self.total_amount = self.calculate_total()
168
-
169
  if calculation_notes:
170
  self.calculation_notes = calculation_notes
171
 
 
13
  class UserPayroll(BaseModel):
14
  """
15
  User Payroll (Worker Compensation & Payment Tracking)
16
+
17
  Tracks all work done by users and their compensation.
18
+ Used for all workers (casual, hourly, salaried).
19
+
 
20
  Key Features:
21
+ - Flexible pay periods (weekly, bi-weekly, monthly)
22
+ - Aggregated work summary from timesheets (tickets, days)
23
+ - Simple compensation: base_earnings + bonus - deductions
24
  - Payment tracking with reference numbers
25
  - Optimistic locking for concurrent updates
26
+
27
  Business Rules:
28
  - One payroll record per user per project per pay period
29
  - period_end_date >= period_start_date
30
  - All amounts must be non-negative
31
+ - total_amount = base_earnings + bonus - deductions
32
  - Can only be paid once (is_paid flag)
33
  - Version column prevents race conditions during recalculation
34
+
35
+ Compensation Calculation:
36
+ - base_earnings calculated from project_role compensation_type:
37
+ * FIXED_RATE: days_worked × base_rate (or hours × base_rate for hourly)
38
+ * PER_UNIT: tickets_closed × per_unit_rate
39
+ * COMMISSION: ticket_value × commission_percentage
40
+ * FIXED_PLUS_COMMISSION: (days × base_rate) + (ticket_value × commission%)
41
+
42
  Generation:
43
  - Automatically generated weekly (Monday morning for previous Mon-Sun)
44
  - Recalculated if timesheets are corrected
 
56
  period_start_date = Column(Date, nullable=False, index=True)
57
  period_end_date = Column(Date, nullable=False, index=True)
58
 
59
+ # Work Summary (aggregated from timesheets)
60
  tickets_closed = Column(Integer, default=0, nullable=False)
 
61
  days_worked = Column(Integer, default=0, nullable=False)
62
+
63
  # Compensation Breakdown
64
+ base_earnings = Column(Numeric(12, 2), default=0, nullable=False) # Calculated from compensation_type
65
+ bonus_amount = Column(Numeric(12, 2), default=0, nullable=False) # Performance bonuses (manual)
 
66
  deductions = Column(Numeric(12, 2), default=0, nullable=False) # Deductions (advances, etc.)
67
  total_amount = Column(Numeric(12, 2), nullable=False) # Total compensation
68
 
 
91
 
92
  # Constraints
93
  __table_args__ = (
94
+ UniqueConstraint('user_id', 'project_id', 'period_start_date', 'period_end_date',
95
  name='uq_user_payroll_period'),
96
  CheckConstraint('period_end_date >= period_start_date', name='chk_valid_period'),
97
  CheckConstraint('tickets_closed >= 0', name='chk_positive_tickets'),
 
98
  CheckConstraint('days_worked >= 0', name='chk_positive_days'),
99
+ CheckConstraint('base_earnings >= 0', name='chk_positive_base_earnings'),
 
100
  CheckConstraint('bonus_amount >= 0', name='chk_positive_bonus'),
101
  CheckConstraint('deductions >= 0', name='chk_positive_deductions'),
102
  CheckConstraint('total_amount >= 0', name='chk_positive_total'),
 
119
  @property
120
  def net_earnings(self) -> Decimal:
121
  """Calculate net earnings (before deductions)"""
122
+ return Decimal(self.base_earnings or 0) + Decimal(self.bonus_amount or 0)
123
 
124
  def calculate_total(self) -> Decimal:
125
  """Calculate total compensation amount"""
 
147
  def recalculate_from_data(
148
  self,
149
  tickets_closed: int,
 
150
  days_worked: int,
151
+ base_earnings: Decimal,
 
152
  bonus: Decimal = Decimal('0'),
153
  deductions: Decimal = Decimal('0'),
154
  calculation_notes: Optional[str] = None
 
156
  """Recalculate payroll from fresh data (used when timesheets are corrected)"""
157
  if self.is_paid:
158
  raise ValueError("Cannot recalculate paid payroll")
159
+
160
  self.tickets_closed = tickets_closed
 
161
  self.days_worked = days_worked
162
+ self.base_earnings = base_earnings
 
163
  self.bonus_amount = bonus
164
  self.deductions = deductions
165
  self.total_amount = self.calculate_total()
166
+
167
  if calculation_notes:
168
  self.calculation_notes = calculation_notes
169
 
src/app/schemas/notification.py CHANGED
@@ -38,6 +38,7 @@ class NotificationSourceType(str, Enum):
38
  SALES_ORDER = "sales_order"
39
  INCIDENT = "incident"
40
  TASK = "task"
 
41
  SYSTEM = "system"
42
  USER = "user"
43
 
@@ -56,6 +57,9 @@ class NotificationType(str, Enum):
56
  REMINDER = "reminder"
57
  ALERT = "alert"
58
  INFO = "info"
 
 
 
59
 
60
 
61
  # ============================================
 
38
  SALES_ORDER = "sales_order"
39
  INCIDENT = "incident"
40
  TASK = "task"
41
+ PAYROLL = "payroll"
42
  SYSTEM = "system"
43
  USER = "user"
44
 
 
57
  REMINDER = "reminder"
58
  ALERT = "alert"
59
  INFO = "info"
60
+ PAYMENT = "payment"
61
+ PAYROLL_EXPORTED = "payroll_exported"
62
+ TICKET_DROPPED = "ticket_dropped"
63
 
64
 
65
  # ============================================
src/app/schemas/payroll.py CHANGED
@@ -56,8 +56,7 @@ class PayrollBatchGenerateRequest(BaseModel):
56
 
57
  class PayrollUpdateRequest(BaseModel):
58
  """Request to update payroll details (before payment)"""
59
- flat_rate_amount: Optional[Decimal] = Field(None, ge=0, description="Weekly flat rate")
60
- ticket_earnings: Optional[Decimal] = Field(None, ge=0, description="Earnings from tickets")
61
  bonus_amount: Optional[Decimal] = Field(None, ge=0, description="Performance bonuses")
62
  deductions: Optional[Decimal] = Field(None, ge=0, description="Deductions (advances, etc.)")
63
  calculation_notes: Optional[str] = Field(None, max_length=1000, description="Notes about calculation")
@@ -96,12 +95,10 @@ class PayrollResponse(BaseModel):
96
 
97
  # Work Summary
98
  tickets_closed: int
99
- hours_worked: Decimal
100
  days_worked: int
101
-
102
  # Compensation Breakdown
103
- flat_rate_amount: Decimal
104
- ticket_earnings: Decimal
105
  bonus_amount: Decimal
106
  deductions: Decimal
107
  total_amount: Decimal
 
56
 
57
  class PayrollUpdateRequest(BaseModel):
58
  """Request to update payroll details (before payment)"""
59
+ base_earnings: Optional[Decimal] = Field(None, ge=0, description="Base earnings (calculated from compensation)")
 
60
  bonus_amount: Optional[Decimal] = Field(None, ge=0, description="Performance bonuses")
61
  deductions: Optional[Decimal] = Field(None, ge=0, description="Deductions (advances, etc.)")
62
  calculation_notes: Optional[str] = Field(None, max_length=1000, description="Notes about calculation")
 
95
 
96
  # Work Summary
97
  tickets_closed: int
 
98
  days_worked: int
99
+
100
  # Compensation Breakdown
101
+ base_earnings: Decimal
 
102
  bonus_amount: Decimal
103
  deductions: Decimal
104
  total_amount: Decimal
src/app/schemas/project.py CHANGED
@@ -363,47 +363,64 @@ class ProjectRoleBase(BaseModel):
363
  """Base schema for project role"""
364
  role_name: str = Field(..., min_length=1, max_length=100, description="Role name")
365
  description: Optional[str] = None
366
- compensation_type: Literal[
367
- "per_day", "per_week", "per_ticket", # NEW - Simple types
368
- "flat_rate", "commission", "hybrid", "hourly", "custom" # OLD - Backward compatibility
369
- ]
370
 
371
 
372
  class ProjectRoleCreate(ProjectRoleBase):
373
- """Schema for creating a project role"""
374
- # NEW - Simple cash-based fields (recommended)
375
- daily_rate: Optional[Decimal] = Field(None, ge=0, description="Fixed daily rate (e.g., 1000 KES/day)")
376
- weekly_rate: Optional[Decimal] = Field(None, ge=0, description="Fixed weekly rate (e.g., 7000 KES/week)")
377
- per_ticket_rate: Optional[Decimal] = Field(None, ge=0, description="Fixed per ticket (e.g., 500 KES/ticket)")
378
-
379
- # OLD - Keep for backward compatibility
380
- flat_rate_amount: Optional[Decimal] = Field(None, ge=0, description="Legacy: Weekly flat rate")
381
- commission_percentage: Optional[Decimal] = Field(None, ge=0, le=100, description="Commission %")
382
- base_amount: Optional[Decimal] = Field(None, ge=0, description="Base amount per ticket")
383
- bonus_percentage: Optional[Decimal] = Field(None, ge=0, le=100, description="Bonus %")
384
- hourly_rate: Optional[Decimal] = Field(None, ge=0, description="Hourly rate")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
 
387
  class ProjectRoleUpdate(BaseModel):
388
  """Schema for updating a project role"""
389
  role_name: Optional[str] = Field(None, min_length=1, max_length=100)
390
  description: Optional[str] = None
391
- compensation_type: Optional[Literal[
392
- "per_day", "per_week", "per_ticket",
393
- "flat_rate", "commission", "hybrid", "hourly", "custom"
394
- ]] = None
395
-
396
- # NEW - Simple fields
397
- daily_rate: Optional[Decimal] = Field(None, ge=0)
398
- weekly_rate: Optional[Decimal] = Field(None, ge=0)
399
- per_ticket_rate: Optional[Decimal] = Field(None, ge=0)
400
-
401
- # OLD - Backward compatibility
402
- flat_rate_amount: Optional[Decimal] = Field(None, ge=0)
403
  commission_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
404
- base_amount: Optional[Decimal] = Field(None, ge=0)
405
- bonus_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
406
- hourly_rate: Optional[Decimal] = Field(None, ge=0)
407
  is_active: Optional[bool] = None
408
 
409
 
@@ -411,23 +428,17 @@ class ProjectRoleResponse(ProjectRoleBase):
411
  """Schema for project role response"""
412
  id: UUID
413
  project_id: UUID
414
-
415
- # NEW - Simple fields
416
- daily_rate: Optional[Decimal]
417
- weekly_rate: Optional[Decimal]
418
- per_ticket_rate: Optional[Decimal]
419
-
420
- # OLD - Backward compatibility
421
- flat_rate_amount: Optional[Decimal]
422
  commission_percentage: Optional[Decimal]
423
- base_amount: Optional[Decimal]
424
- bonus_percentage: Optional[Decimal]
425
- hourly_rate: Optional[Decimal]
426
-
427
  is_active: bool
428
  created_at: datetime
429
  updated_at: datetime
430
-
431
  class Config:
432
  from_attributes = True
433
 
 
363
  """Base schema for project role"""
364
  role_name: str = Field(..., min_length=1, max_length=100, description="Role name")
365
  description: Optional[str] = None
366
+ compensation_type: Literal["FIXED_RATE", "PER_UNIT", "COMMISSION", "FIXED_PLUS_COMMISSION"]
 
 
 
367
 
368
 
369
  class ProjectRoleCreate(ProjectRoleBase):
370
+ """
371
+ Schema for creating a project role
372
+
373
+ Field requirements by compensation_type:
374
+ - FIXED_RATE: base_rate + rate_period required
375
+ - PER_UNIT: per_unit_rate required
376
+ - COMMISSION: commission_percentage required
377
+ - FIXED_PLUS_COMMISSION: base_rate + rate_period + commission_percentage required
378
+ """
379
+ # For FIXED_RATE and FIXED_PLUS_COMMISSION
380
+ base_rate: Optional[Decimal] = Field(None, ge=0, description="Base rate amount (e.g., 1000 KES/day, 25 USD/hour)")
381
+ rate_period: Optional[Literal["HOUR", "DAY", "WEEK", "MONTH"]] = Field(None, description="Time period for base_rate")
382
+
383
+ # For PER_UNIT
384
+ per_unit_rate: Optional[Decimal] = Field(None, ge=0, description="Per-ticket/job rate (e.g., 500 KES/ticket)")
385
+
386
+ # For COMMISSION and FIXED_PLUS_COMMISSION
387
+ commission_percentage: Optional[Decimal] = Field(None, ge=0, le=100, description="Commission percentage (0-100)")
388
+
389
+ @model_validator(mode='after')
390
+ def validate_compensation_fields(self):
391
+ """Validate that required fields are provided based on compensation_type"""
392
+ comp_type = self.compensation_type
393
+
394
+ if comp_type == "FIXED_RATE":
395
+ if not self.base_rate or not self.rate_period:
396
+ raise ValueError("FIXED_RATE requires base_rate and rate_period")
397
+
398
+ elif comp_type == "PER_UNIT":
399
+ if not self.per_unit_rate:
400
+ raise ValueError("PER_UNIT requires per_unit_rate")
401
+
402
+ elif comp_type == "COMMISSION":
403
+ if not self.commission_percentage:
404
+ raise ValueError("COMMISSION requires commission_percentage")
405
+
406
+ elif comp_type == "FIXED_PLUS_COMMISSION":
407
+ if not self.base_rate or not self.rate_period or not self.commission_percentage:
408
+ raise ValueError("FIXED_PLUS_COMMISSION requires base_rate, rate_period, and commission_percentage")
409
+
410
+ return self
411
 
412
 
413
  class ProjectRoleUpdate(BaseModel):
414
  """Schema for updating a project role"""
415
  role_name: Optional[str] = Field(None, min_length=1, max_length=100)
416
  description: Optional[str] = None
417
+ compensation_type: Optional[Literal["FIXED_RATE", "PER_UNIT", "COMMISSION", "FIXED_PLUS_COMMISSION"]] = None
418
+
419
+ # Compensation fields
420
+ base_rate: Optional[Decimal] = Field(None, ge=0)
421
+ rate_period: Optional[Literal["HOUR", "DAY", "WEEK", "MONTH"]] = None
422
+ per_unit_rate: Optional[Decimal] = Field(None, ge=0)
 
 
 
 
 
 
423
  commission_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
 
 
 
424
  is_active: Optional[bool] = None
425
 
426
 
 
428
  """Schema for project role response"""
429
  id: UUID
430
  project_id: UUID
431
+
432
+ # Compensation fields
433
+ base_rate: Optional[Decimal]
434
+ rate_period: Optional[str]
435
+ per_unit_rate: Optional[Decimal]
 
 
 
436
  commission_percentage: Optional[Decimal]
437
+
 
 
 
438
  is_active: bool
439
  created_at: datetime
440
  updated_at: datetime
441
+
442
  class Config:
443
  from_attributes = True
444
 
src/app/services/expense_service.py CHANGED
@@ -10,12 +10,15 @@ Handles:
10
 
11
  from sqlalchemy.orm import Session, joinedload
12
  from sqlalchemy import func, and_, or_
13
- from typing import Optional, List, Dict
14
  from uuid import UUID
15
  from decimal import Decimal
16
  from datetime import datetime
17
  from fastapi import HTTPException, status
18
 
 
 
 
19
  from app.models.ticket_expense import TicketExpense
20
  from app.models.ticket_assignment import TicketAssignment
21
  from app.models.ticket import Ticket
@@ -30,6 +33,8 @@ from app.schemas.ticket_expense import (
30
  PaymentMethod,
31
  )
32
  from app.core.exceptions import NotFoundException, ValidationException
 
 
33
  import logging
34
 
35
  logger = logging.getLogger(__name__)
@@ -100,34 +105,61 @@ class ExpenseService:
100
  f"location_verified={location_verified}"
101
  )
102
 
103
- # Notify PM/Dispatcher about expense submission for approval
104
  try:
105
- from app.services.notification_helper import NotificationHelper
106
  from app.models.user import User
107
- import asyncio
108
-
 
109
  # Get the submitting user
110
  submitted_by = db.query(User).filter(User.id == incurred_by_user_id).first()
111
-
112
  # Get ticket to find project
113
- from app.models.ticket import Ticket
114
  ticket = db.query(Ticket).filter(Ticket.id == assignment.ticket_id).first()
115
-
116
  if submitted_by and ticket:
117
- # Get PMs and dispatchers for this project
118
- notify_users = NotificationHelper.get_project_managers_and_dispatchers(db, ticket.project_id)
119
-
120
- if notify_users:
121
- asyncio.create_task(
122
- NotificationHelper.notify_expense_submitted(
123
- db=db,
124
- expense=expense,
125
- submitted_by=submitted_by,
126
- notify_users=notify_users
127
- )
128
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  except Exception as e:
130
- logger.error(f"Failed to send expense submission notification: {str(e)}")
131
 
132
  return expense
133
 
@@ -304,7 +336,8 @@ class ExpenseService:
304
  db: Session,
305
  expense_id: UUID,
306
  data: TicketExpenseApprove,
307
- approved_by_user_id: UUID
 
308
  ) -> TicketExpense:
309
  """
310
  Approve or reject an expense
@@ -344,35 +377,59 @@ class ExpenseService:
344
  status = "approved" if data.is_approved else "rejected"
345
  logger.info(f"Expense {expense_id} {status} by user {approved_by_user_id}")
346
 
347
- # Notify agent about approval/rejection
348
  try:
349
- from app.services.notification_helper import NotificationHelper
350
- from app.models.user import User
351
- import asyncio
352
-
353
  # Get the agent who submitted the expense
354
  agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
355
  approver = db.query(User).filter(User.id == approved_by_user_id).first()
356
-
357
  if agent and approver:
358
  if data.is_approved:
359
- asyncio.create_task(
360
- NotificationHelper.notify_expense_approved(
361
- db=db,
362
- expense=expense,
363
- approved_by=approver,
364
- agent=agent
365
- )
 
 
 
 
 
 
 
 
 
 
366
  )
367
  else:
368
- asyncio.create_task(
369
- NotificationHelper.notify_expense_rejected(
370
- db=db,
371
- expense=expense,
372
- rejected_by=approver,
373
- agent=agent,
374
- reason=data.rejection_reason or "No reason provided"
375
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  )
377
  except Exception as e:
378
  logger.error(f"Failed to send expense approval/rejection notification: {str(e)}")
@@ -383,7 +440,8 @@ class ExpenseService:
383
  def mark_paid(
384
  db: Session,
385
  expense_id: UUID,
386
- data: TicketExpenseMarkPaid
 
387
  ) -> TicketExpense:
388
  """
389
  Mark expense as paid
@@ -432,25 +490,41 @@ class ExpenseService:
432
  f"reference={data.payment_reference}"
433
  )
434
 
435
- # Notify agent about payment
436
  try:
437
- from app.services.notification_helper import NotificationHelper
438
- from app.models.user import User
439
- import asyncio
440
-
441
  # Get the agent who submitted the expense
442
  agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
443
- paid_by = db.query(User).filter(User.id == data.paid_to_user_id).first()
444
-
445
- if agent and paid_by:
446
- asyncio.create_task(
447
- NotificationHelper.notify_expense_paid(
448
- db=db,
449
- expense=expense,
450
- paid_by=paid_by,
451
- agent=agent
452
- )
 
 
 
 
 
 
 
 
 
 
 
 
453
  )
 
 
 
 
 
 
 
 
454
  except Exception as e:
455
  logger.error(f"Failed to send expense payment notification: {str(e)}")
456
 
 
10
 
11
  from sqlalchemy.orm import Session, joinedload
12
  from sqlalchemy import func, and_, or_
13
+ from typing import Optional, List, Dict, TYPE_CHECKING
14
  from uuid import UUID
15
  from decimal import Decimal
16
  from datetime import datetime
17
  from fastapi import HTTPException, status
18
 
19
+ if TYPE_CHECKING:
20
+ from fastapi import BackgroundTasks
21
+
22
  from app.models.ticket_expense import TicketExpense
23
  from app.models.ticket_assignment import TicketAssignment
24
  from app.models.ticket import Ticket
 
33
  PaymentMethod,
34
  )
35
  from app.core.exceptions import NotFoundException, ValidationException
36
+ from app.services.notification_creator import NotificationCreator
37
+ from app.services.notification_delivery import NotificationDelivery
38
  import logging
39
 
40
  logger = logging.getLogger(__name__)
 
105
  f"location_verified={location_verified}"
106
  )
107
 
108
+ # Create notifications for PM/Dispatcher about expense submission (Tier 1 - Synchronous)
109
  try:
110
+ from app.services.notification_creator import NotificationCreator
111
  from app.models.user import User
112
+ from app.models.ticket import Ticket
113
+ from app.models.enums import AppRole
114
+
115
  # Get the submitting user
116
  submitted_by = db.query(User).filter(User.id == incurred_by_user_id).first()
117
+
118
  # Get ticket to find project
 
119
  ticket = db.query(Ticket).filter(Ticket.id == assignment.ticket_id).first()
120
+
121
  if submitted_by and ticket:
122
+ # Create notifications for all PMs and dispatchers in the project
123
+ notifications = NotificationCreator.notify_project_team(
124
+ db=db,
125
+ project_id=ticket.project_id,
126
+ title=f"💰 Expense Submitted: {data.total_cost:,.2f} KES",
127
+ message=(
128
+ f"{submitted_by.name} submitted an expense for approval.\n\n"
129
+ f"📋 Details:\n"
130
+ f"• Category: {data.category}\n"
131
+ f"• Amount: {data.total_cost:,.2f} KES\n"
132
+ f"• Ticket: {ticket.ticket_name or ticket.ticket_type}\n"
133
+ f"• Description: {data.description or 'No description'}\n\n"
134
+ f"⚡ Please review and approve/reject this expense."
135
+ ),
136
+ source_type="expense",
137
+ source_id=expense.id,
138
+ notification_type="expense_submitted",
139
+ roles=[AppRole.PROJECT_MANAGER, AppRole.DISPATCHER],
140
+ channel="in_app",
141
+ metadata={
142
+ "expense_id": str(expense.id),
143
+ "ticket_id": str(ticket.id),
144
+ "ticket_name": ticket.ticket_name,
145
+ "submitted_by_user_id": str(submitted_by.id),
146
+ "submitted_by_name": submitted_by.name,
147
+ "category": data.category,
148
+ "total_cost": float(data.total_cost),
149
+ "requires_action": True,
150
+ "action_url": f"/expenses/{expense.id}"
151
+ }
152
+ )
153
+
154
+ # Commit notifications
155
+ db.commit()
156
+
157
+ logger.info(
158
+ f"Created {len(notifications)} expense submission notifications for expense {expense.id}"
159
+ )
160
+
161
  except Exception as e:
162
+ logger.error(f"Failed to create expense submission notification: {str(e)}", exc_info=True)
163
 
164
  return expense
165
 
 
336
  db: Session,
337
  expense_id: UUID,
338
  data: TicketExpenseApprove,
339
+ approved_by_user_id: UUID,
340
+ background_tasks: Optional['BackgroundTasks'] = None
341
  ) -> TicketExpense:
342
  """
343
  Approve or reject an expense
 
377
  status = "approved" if data.is_approved else "rejected"
378
  logger.info(f"Expense {expense_id} {status} by user {approved_by_user_id}")
379
 
380
+ # Notify agent about approval/rejection (Tier 1 - Synchronous)
381
  try:
 
 
 
 
382
  # Get the agent who submitted the expense
383
  agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
384
  approver = db.query(User).filter(User.id == approved_by_user_id).first()
385
+
386
  if agent and approver:
387
  if data.is_approved:
388
+ notification = NotificationCreator.create(
389
+ db=db,
390
+ user_id=agent.id,
391
+ title=f"✅ Expense Approved",
392
+ message=f"Your expense of {expense.total_cost} {expense.currency} has been approved by {approver.full_name}.",
393
+ source_type="expense",
394
+ source_id=expense.id,
395
+ notification_type="expense_approved",
396
+ channel="in_app",
397
+ project_id=expense.ticket.project_id if expense.ticket else None,
398
+ metadata={
399
+ "expense_id": str(expense.id),
400
+ "amount": str(expense.total_cost),
401
+ "currency": expense.currency,
402
+ "approved_by": approver.full_name,
403
+ "action_url": f"/expenses/{expense.id}"
404
+ }
405
  )
406
  else:
407
+ notification = NotificationCreator.create(
408
+ db=db,
409
+ user_id=agent.id,
410
+ title=f"❌ Expense Rejected",
411
+ message=f"Your expense of {expense.total_cost} {expense.currency} was rejected by {approver.full_name}.\n\nReason: {data.rejection_reason or 'No reason provided'}",
412
+ source_type="expense",
413
+ source_id=expense.id,
414
+ notification_type="expense_rejected",
415
+ channel="in_app",
416
+ project_id=expense.ticket.project_id if expense.ticket else None,
417
+ metadata={
418
+ "expense_id": str(expense.id),
419
+ "amount": str(expense.total_cost),
420
+ "currency": expense.currency,
421
+ "rejected_by": approver.full_name,
422
+ "rejection_reason": data.rejection_reason,
423
+ "action_url": f"/expenses/{expense.id}"
424
+ }
425
+ )
426
+ db.commit()
427
+
428
+ # Queue delivery (Tier 2 - Asynchronous)
429
+ if background_tasks:
430
+ NotificationDelivery.queue_delivery(
431
+ background_tasks=background_tasks,
432
+ notification_id=notification.id
433
  )
434
  except Exception as e:
435
  logger.error(f"Failed to send expense approval/rejection notification: {str(e)}")
 
440
  def mark_paid(
441
  db: Session,
442
  expense_id: UUID,
443
+ data: TicketExpenseMarkPaid,
444
+ background_tasks: Optional['BackgroundTasks'] = None
445
  ) -> TicketExpense:
446
  """
447
  Mark expense as paid
 
490
  f"reference={data.payment_reference}"
491
  )
492
 
493
+ # Notify agent about payment (Tier 1 - Synchronous)
494
  try:
 
 
 
 
495
  # Get the agent who submitted the expense
496
  agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
497
+ paid_to = db.query(User).filter(User.id == data.paid_to_user_id).first()
498
+
499
+ if agent:
500
+ notification = NotificationCreator.create(
501
+ db=db,
502
+ user_id=agent.id,
503
+ title=f"💰 Expense Paid",
504
+ message=f"Your expense of {expense.total_cost} {expense.currency} has been paid.\n\nPayment Method: {expense.payment_method}\nReference: {data.payment_reference or 'N/A'}",
505
+ source_type="expense",
506
+ source_id=expense.id,
507
+ notification_type="expense_paid",
508
+ channel="in_app",
509
+ project_id=expense.ticket.project_id if expense.ticket else None,
510
+ metadata={
511
+ "expense_id": str(expense.id),
512
+ "amount": str(expense.total_cost),
513
+ "currency": expense.currency,
514
+ "payment_method": expense.payment_method,
515
+ "payment_reference": data.payment_reference,
516
+ "paid_to": paid_to.full_name if paid_to else None,
517
+ "action_url": f"/expenses/{expense.id}"
518
+ }
519
  )
520
+ db.commit()
521
+
522
+ # Queue delivery (Tier 2 - Asynchronous)
523
+ if background_tasks:
524
+ NotificationDelivery.queue_delivery(
525
+ background_tasks=background_tasks,
526
+ notification_id=notification.id
527
+ )
528
  except Exception as e:
529
  logger.error(f"Failed to send expense payment notification: {str(e)}")
530
 
src/app/services/invoice_generation_service.py CHANGED
@@ -30,6 +30,7 @@ from app.models.project import Project
30
  from app.models.audit_log import AuditLog
31
  from app.models.enums import TicketStatus, TicketSource, AppRole, AuditAction
32
  from app.services.contractor_invoice_service import ContractorInvoiceService
 
33
 
34
  logger = logging.getLogger(__name__)
35
 
@@ -115,12 +116,12 @@ class InvoiceGenerationService:
115
  ticket_ids: List[UUID],
116
  invoice_metadata: Dict,
117
  current_user: User
118
- ) -> Tuple[ContractorInvoice, str, str]:
119
  """
120
  Generate invoice from selected tickets.
121
  No pricing required - just proof of work.
122
-
123
- Returns: (invoice, viewing_link, csv_download_link)
124
 
125
  Steps:
126
  1. Validate tickets are completed and not invoiced
@@ -225,7 +226,8 @@ class InvoiceGenerationService:
225
  viewing_link = f"{base_url}/invoices/view?token={viewing_token}"
226
  csv_link = f"{base_url}/api/v1/invoices/{invoice.id}/export/csv"
227
 
228
- # Create notifications for ALL PMs in the project
 
229
  project = db.query(Project).filter(Project.id == project_id).first()
230
  if project:
231
  # Get all PMs for this contractor
@@ -235,19 +237,19 @@ class InvoiceGenerationService:
235
  User.is_active == True,
236
  User.deleted_at.is_(None)
237
  ).all()
238
-
239
  for pm in pms:
240
- notification = Notification(
 
241
  user_id=pm.id,
242
- project_id=project_id,
243
- source_type="contractor_invoice",
244
- source_id=invoice.id,
245
  title=f"Invoice {invoice.invoice_number} Generated",
246
  message=f"Work completion invoice created for {len(tickets)} tickets. View and download CSV.",
 
 
247
  notification_type="invoice_generated",
248
- channel=NotificationChannel.IN_APP,
249
- status=NotificationStatus.PENDING,
250
- additional_metadata={
251
  "invoice_id": str(invoice.id),
252
  "invoice_number": invoice.invoice_number,
253
  "ticket_ids": [str(t.id) for t in tickets],
@@ -255,14 +257,14 @@ class InvoiceGenerationService:
255
  "viewing_link": viewing_link,
256
  "csv_download_link": csv_link,
257
  "sales_order_numbers": [
258
- item.get('sales_order_number')
259
- for item in line_items
260
  if item.get('sales_order_number')
261
  ]
262
  }
263
  )
264
- db.add(notification)
265
-
266
  db.commit()
267
 
268
  # Audit log
@@ -286,8 +288,8 @@ class InvoiceGenerationService:
286
  db.commit()
287
 
288
  logger.info(f"Generated invoice {invoice.invoice_number} for {len(tickets)} tickets by user {current_user.email}")
289
-
290
- return invoice, viewing_link, csv_link
291
 
292
 
293
  @staticmethod
 
30
  from app.models.audit_log import AuditLog
31
  from app.models.enums import TicketStatus, TicketSource, AppRole, AuditAction
32
  from app.services.contractor_invoice_service import ContractorInvoiceService
33
+ from app.services.notification_creator import NotificationCreator
34
 
35
  logger = logging.getLogger(__name__)
36
 
 
116
  ticket_ids: List[UUID],
117
  invoice_metadata: Dict,
118
  current_user: User
119
+ ) -> Tuple[ContractorInvoice, str, str, List[UUID]]:
120
  """
121
  Generate invoice from selected tickets.
122
  No pricing required - just proof of work.
123
+
124
+ Returns: (invoice, viewing_link, csv_download_link, notification_ids)
125
 
126
  Steps:
127
  1. Validate tickets are completed and not invoiced
 
226
  viewing_link = f"{base_url}/invoices/view?token={viewing_token}"
227
  csv_link = f"{base_url}/api/v1/invoices/{invoice.id}/export/csv"
228
 
229
+ # Create notifications for ALL PMs in the project (Tier 1 - Synchronous)
230
+ notification_ids = []
231
  project = db.query(Project).filter(Project.id == project_id).first()
232
  if project:
233
  # Get all PMs for this contractor
 
237
  User.is_active == True,
238
  User.deleted_at.is_(None)
239
  ).all()
240
+
241
  for pm in pms:
242
+ notification = NotificationCreator.create(
243
+ db=db,
244
  user_id=pm.id,
 
 
 
245
  title=f"Invoice {invoice.invoice_number} Generated",
246
  message=f"Work completion invoice created for {len(tickets)} tickets. View and download CSV.",
247
+ source_type="contractor_invoice",
248
+ source_id=invoice.id,
249
  notification_type="invoice_generated",
250
+ channel="in_app",
251
+ project_id=project_id,
252
+ metadata={
253
  "invoice_id": str(invoice.id),
254
  "invoice_number": invoice.invoice_number,
255
  "ticket_ids": [str(t.id) for t in tickets],
 
257
  "viewing_link": viewing_link,
258
  "csv_download_link": csv_link,
259
  "sales_order_numbers": [
260
+ item.get('sales_order_number')
261
+ for item in line_items
262
  if item.get('sales_order_number')
263
  ]
264
  }
265
  )
266
+ notification_ids.append(notification.id)
267
+
268
  db.commit()
269
 
270
  # Audit log
 
288
  db.commit()
289
 
290
  logger.info(f"Generated invoice {invoice.invoice_number} for {len(tickets)} tickets by user {current_user.email}")
291
+
292
+ return invoice, viewing_link, csv_link, notification_ids
293
 
294
 
295
  @staticmethod
src/app/services/notification_creator.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Notification Creator - Tier 1: Synchronous Notification Creation
3
+
4
+ This is the ONLY way to create notifications in the system.
5
+ All services must use this to ensure consistency and reliability.
6
+
7
+ Architecture:
8
+ - Tier 1 (This file): Create notification records synchronously in database
9
+ - Tier 2 (notification_delivery.py): Deliver notifications via external channels (WhatsApp, Email, SMS)
10
+
11
+ Design Principles:
12
+ 1. Synchronous - Notifications are created immediately, guaranteed to be saved
13
+ 2. Transaction-safe - Notifications roll back with parent operation
14
+ 3. Simple - No async complexity, easy to test and debug
15
+ 4. Consistent - One pattern used everywhere in the codebase
16
+ 5. Scalable - Designed to handle 10,000+ notifications/day
17
+
18
+ Usage:
19
+ from app.services.notification_creator import NotificationCreator
20
+
21
+ # Single notification
22
+ notification = NotificationCreator.create(
23
+ db=db,
24
+ user_id=user.id,
25
+ title="Ticket Assigned",
26
+ message="You have been assigned to ticket #123",
27
+ source_type="ticket",
28
+ source_id=ticket.id,
29
+ notification_type="assignment",
30
+ channel="in_app"
31
+ )
32
+ db.commit()
33
+
34
+ # Bulk notifications
35
+ notifications = NotificationCreator.create_bulk(
36
+ db=db,
37
+ user_ids=[user1.id, user2.id, user3.id],
38
+ title="Project Update",
39
+ message="Project status changed",
40
+ source_type="project",
41
+ source_id=project.id,
42
+ notification_type="status_change"
43
+ )
44
+ db.commit()
45
+ """
46
+
47
+ import logging
48
+ from sqlalchemy.orm import Session
49
+ from uuid import UUID
50
+ from typing import Optional, List, Dict, Any
51
+ from datetime import datetime
52
+
53
+ from app.models.notification import Notification, NotificationChannel, NotificationStatus
54
+ from app.models.user import User
55
+ from app.models.enums import AppRole
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ class NotificationCreator:
61
+ """
62
+ Synchronous notification creator.
63
+ Creates notification records in database - does NOT send them.
64
+ Delivery is handled separately by NotificationDelivery service.
65
+ """
66
+
67
+ @staticmethod
68
+ def create(
69
+ db: Session,
70
+ user_id: UUID,
71
+ title: str,
72
+ message: str,
73
+ source_type: str,
74
+ source_id: Optional[UUID],
75
+ notification_type: str,
76
+ channel: str = "in_app",
77
+ metadata: Optional[Dict[str, Any]] = None,
78
+ project_id: Optional[UUID] = None
79
+ ) -> Notification:
80
+ """
81
+ Create a single notification record.
82
+
83
+ Args:
84
+ db: Database session
85
+ user_id: User to notify
86
+ title: Notification title (short, for display)
87
+ message: Notification message (detailed)
88
+ source_type: Type of entity (ticket, project, expense, payroll, etc.)
89
+ source_id: ID of the source entity
90
+ notification_type: Type of notification (assignment, payment, alert, etc.)
91
+ channel: Delivery channel (in_app, whatsapp, email, sms, push)
92
+ metadata: Additional data (action URLs, context, etc.)
93
+ project_id: Optional project ID for filtering
94
+
95
+ Returns:
96
+ Notification: Created notification record
97
+
98
+ Example:
99
+ notification = NotificationCreator.create(
100
+ db=db,
101
+ user_id=agent.id,
102
+ title="New Ticket Assigned",
103
+ message="You have been assigned to install fiber at Customer A",
104
+ source_type="ticket",
105
+ source_id=ticket.id,
106
+ notification_type="assignment",
107
+ channel="whatsapp",
108
+ metadata={
109
+ "ticket_number": "TKT-001",
110
+ "priority": "high",
111
+ "action_url": f"/tickets/{ticket.id}"
112
+ },
113
+ project_id=ticket.project_id
114
+ )
115
+ db.commit()
116
+ """
117
+ try:
118
+ notification = Notification(
119
+ user_id=user_id,
120
+ project_id=project_id,
121
+ source_type=source_type,
122
+ source_id=source_id,
123
+ title=title,
124
+ message=message,
125
+ notification_type=notification_type,
126
+ channel=NotificationChannel(channel),
127
+ status=NotificationStatus.PENDING,
128
+ additional_metadata=metadata or {},
129
+ created_at=datetime.utcnow()
130
+ )
131
+
132
+ db.add(notification)
133
+ db.flush() # Get ID without committing (caller controls commit)
134
+
135
+ logger.debug(
136
+ f"Created notification {notification.id}: {notification_type} for user {user_id} "
137
+ f"via {channel}"
138
+ )
139
+
140
+ return notification
141
+
142
+ except Exception as e:
143
+ logger.error(f"Failed to create notification: {str(e)}", exc_info=True)
144
+ raise
145
+
146
+ @staticmethod
147
+ def create_bulk(
148
+ db: Session,
149
+ user_ids: List[UUID],
150
+ title: str,
151
+ message: str,
152
+ source_type: str,
153
+ source_id: Optional[UUID],
154
+ notification_type: str,
155
+ channel: str = "in_app",
156
+ metadata: Optional[Dict[str, Any]] = None,
157
+ project_id: Optional[UUID] = None
158
+ ) -> List[Notification]:
159
+ """
160
+ Create multiple notifications for different users with the same content.
161
+
162
+ Args:
163
+ db: Database session
164
+ user_ids: List of user IDs to notify
165
+ title: Notification title (same for all)
166
+ message: Notification message (same for all)
167
+ source_type: Type of entity
168
+ source_id: ID of the source entity
169
+ notification_type: Type of notification
170
+ channel: Delivery channel
171
+ metadata: Additional data (same for all)
172
+ project_id: Optional project ID
173
+
174
+ Returns:
175
+ List[Notification]: List of created notification records
176
+
177
+ Example:
178
+ # Notify all managers about ticket drop
179
+ manager_ids = [pm1.id, pm2.id, dispatcher.id]
180
+ notifications = NotificationCreator.create_bulk(
181
+ db=db,
182
+ user_ids=manager_ids,
183
+ title="⚠️ Ticket Dropped - Action Required",
184
+ message=f"{agent.name} dropped ticket: {ticket.name}",
185
+ source_type="ticket",
186
+ source_id=ticket.id,
187
+ notification_type="ticket_dropped",
188
+ channel="in_app",
189
+ project_id=ticket.project_id
190
+ )
191
+ db.commit()
192
+ """
193
+ notifications = []
194
+
195
+ try:
196
+ for user_id in user_ids:
197
+ notification = NotificationCreator.create(
198
+ db=db,
199
+ user_id=user_id,
200
+ title=title,
201
+ message=message,
202
+ source_type=source_type,
203
+ source_id=source_id,
204
+ notification_type=notification_type,
205
+ channel=channel,
206
+ metadata=metadata,
207
+ project_id=project_id
208
+ )
209
+ notifications.append(notification)
210
+
211
+ logger.info(
212
+ f"Created {len(notifications)} bulk notifications: {notification_type} "
213
+ f"via {channel}"
214
+ )
215
+
216
+ return notifications
217
+
218
+ except Exception as e:
219
+ logger.error(f"Failed to create bulk notifications: {str(e)}", exc_info=True)
220
+ raise
221
+
222
+ @staticmethod
223
+ def notify_project_team(
224
+ db: Session,
225
+ project_id: UUID,
226
+ title: str,
227
+ message: str,
228
+ source_type: str,
229
+ source_id: Optional[UUID],
230
+ notification_type: str,
231
+ roles: Optional[List[AppRole]] = None,
232
+ channel: str = "in_app",
233
+ metadata: Optional[Dict[str, Any]] = None,
234
+ exclude_user_ids: Optional[List[UUID]] = None
235
+ ) -> List[Notification]:
236
+ """
237
+ Notify all team members in a project (optionally filtered by role).
238
+
239
+ Args:
240
+ db: Database session
241
+ project_id: Project ID
242
+ title: Notification title
243
+ message: Notification message
244
+ source_type: Type of entity
245
+ source_id: ID of the source entity
246
+ notification_type: Type of notification
247
+ roles: Optional list of roles to notify (e.g., [AppRole.PROJECT_MANAGER, AppRole.DISPATCHER])
248
+ channel: Delivery channel
249
+ metadata: Additional data
250
+ exclude_user_ids: Optional list of user IDs to exclude
251
+
252
+ Returns:
253
+ List[Notification]: List of created notification records
254
+
255
+ Example:
256
+ # Notify all managers and dispatchers in project
257
+ notifications = NotificationCreator.notify_project_team(
258
+ db=db,
259
+ project_id=ticket.project_id,
260
+ title="Ticket Dropped",
261
+ message=f"{agent.name} dropped ticket",
262
+ source_type="ticket",
263
+ source_id=ticket.id,
264
+ notification_type="ticket_dropped",
265
+ roles=[AppRole.PROJECT_MANAGER, AppRole.DISPATCHER],
266
+ exclude_user_ids=[agent.id] # Don't notify the agent who dropped
267
+ )
268
+ db.commit()
269
+ """
270
+ from app.models.project_team import ProjectTeam
271
+
272
+ try:
273
+ # Build query for project team members
274
+ query = db.query(User).join(ProjectTeam).filter(
275
+ ProjectTeam.project_id == project_id,
276
+ User.is_active == True,
277
+ User.deleted_at.is_(None)
278
+ )
279
+
280
+ # Filter by roles if specified
281
+ if roles:
282
+ role_values = [role.value for role in roles]
283
+ query = query.filter(User.role.in_(role_values))
284
+
285
+ # Exclude specific users if specified
286
+ if exclude_user_ids:
287
+ query = query.filter(~User.id.in_(exclude_user_ids))
288
+
289
+ team_members = query.all()
290
+
291
+ if not team_members:
292
+ logger.warning(
293
+ f"No team members found for project {project_id} "
294
+ f"with roles {roles}"
295
+ )
296
+ return []
297
+
298
+ user_ids = [member.id for member in team_members]
299
+
300
+ notifications = NotificationCreator.create_bulk(
301
+ db=db,
302
+ user_ids=user_ids,
303
+ title=title,
304
+ message=message,
305
+ source_type=source_type,
306
+ source_id=source_id,
307
+ notification_type=notification_type,
308
+ channel=channel,
309
+ metadata=metadata,
310
+ project_id=project_id
311
+ )
312
+
313
+ logger.info(
314
+ f"Notified {len(notifications)} team members in project {project_id}"
315
+ )
316
+
317
+ return notifications
318
+
319
+ except Exception as e:
320
+ logger.error(
321
+ f"Failed to notify project team {project_id}: {str(e)}",
322
+ exc_info=True
323
+ )
324
+ raise
325
+
src/app/services/notification_delivery.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Notification Delivery - Tier 2: Background Notification Delivery
3
+
4
+ Handles delivery of notifications via external channels (WhatsApp, Email, SMS, Push).
5
+ Works with FastAPI BackgroundTasks for non-blocking delivery.
6
+
7
+ Architecture:
8
+ - Tier 1 (notification_creator.py): Creates notification records synchronously
9
+ - Tier 2 (This file): Delivers notifications via external channels asynchronously
10
+
11
+ Design Principles:
12
+ 1. Non-blocking - Doesn't slow down API responses
13
+ 2. Resilient - Handles failures gracefully, marks status
14
+ 3. Configurable - Easy to enable/disable channels for MVP
15
+ 4. Future-proof - Easy to migrate to Celery when needed
16
+
17
+ Usage:
18
+ from fastapi import BackgroundTasks
19
+ from app.services.notification_delivery import NotificationDelivery
20
+
21
+ # In endpoint
22
+ @router.post("/tickets/assign")
23
+ def assign_ticket(background_tasks: BackgroundTasks, ...):
24
+ # Create notification (Tier 1 - sync)
25
+ notification = NotificationCreator.create(
26
+ db=db,
27
+ user_id=agent.id,
28
+ title="Ticket Assigned",
29
+ message="You have been assigned...",
30
+ channel="whatsapp"
31
+ )
32
+ db.commit()
33
+
34
+ # Queue delivery (Tier 2 - async)
35
+ NotificationDelivery.queue_delivery(
36
+ background_tasks=background_tasks,
37
+ notification_id=notification.id
38
+ )
39
+
40
+ return response
41
+
42
+ Configuration:
43
+ Set in .env:
44
+ ENABLE_WHATSAPP_NOTIFICATIONS=false # Disable for MVP
45
+ ENABLE_EMAIL_NOTIFICATIONS=false # Disable for MVP
46
+ ENABLE_SMS_NOTIFICATIONS=false # Disable for MVP
47
+ """
48
+
49
+ import logging
50
+ from uuid import UUID
51
+ from typing import Optional
52
+ from fastapi import BackgroundTasks
53
+
54
+ from app.core.database import SessionLocal
55
+ from app.models.notification import Notification, NotificationChannel, NotificationStatus
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ class NotificationDeliveryConfig:
61
+ """
62
+ Configuration for notification delivery.
63
+ Allows enabling/disabling channels without code changes.
64
+ """
65
+
66
+ # For MVP, all external channels are disabled
67
+ # Set to True in .env when ready to integrate
68
+ ENABLE_WHATSAPP = False
69
+ ENABLE_EMAIL = False
70
+ ENABLE_SMS = False
71
+ ENABLE_PUSH = False
72
+
73
+ # In-app notifications are always enabled (no external API)
74
+ ENABLE_IN_APP = True
75
+
76
+ @classmethod
77
+ def is_channel_enabled(cls, channel: NotificationChannel) -> bool:
78
+ """Check if a delivery channel is enabled"""
79
+ channel_map = {
80
+ NotificationChannel.IN_APP: cls.ENABLE_IN_APP,
81
+ NotificationChannel.WHATSAPP: cls.ENABLE_WHATSAPP,
82
+ NotificationChannel.EMAIL: cls.ENABLE_EMAIL,
83
+ NotificationChannel.SMS: cls.ENABLE_SMS,
84
+ NotificationChannel.PUSH: cls.ENABLE_PUSH,
85
+ }
86
+ return channel_map.get(channel, False)
87
+
88
+
89
+ class NotificationDelivery:
90
+ """
91
+ Handles delivery of notifications via external channels.
92
+ Designed to work with FastAPI BackgroundTasks (MVP) and Celery (future).
93
+ """
94
+
95
+ @staticmethod
96
+ def queue_delivery(
97
+ background_tasks: BackgroundTasks,
98
+ notification_id: UUID
99
+ ) -> None:
100
+ """
101
+ Queue a notification for background delivery.
102
+
103
+ Args:
104
+ background_tasks: FastAPI BackgroundTasks instance
105
+ notification_id: ID of notification to deliver
106
+
107
+ Example:
108
+ NotificationDelivery.queue_delivery(
109
+ background_tasks=background_tasks,
110
+ notification_id=notification.id
111
+ )
112
+ """
113
+ background_tasks.add_task(
114
+ NotificationDelivery._deliver_notification,
115
+ notification_id=notification_id
116
+ )
117
+ logger.debug(f"Queued notification {notification_id} for delivery")
118
+
119
+ @staticmethod
120
+ def queue_bulk_delivery(
121
+ background_tasks: BackgroundTasks,
122
+ notification_ids: list[UUID]
123
+ ) -> None:
124
+ """
125
+ Queue multiple notifications for background delivery.
126
+
127
+ Args:
128
+ background_tasks: FastAPI BackgroundTasks instance
129
+ notification_ids: List of notification IDs to deliver
130
+
131
+ Example:
132
+ NotificationDelivery.queue_bulk_delivery(
133
+ background_tasks=background_tasks,
134
+ notification_ids=[notif.id for notif in notifications]
135
+ )
136
+ """
137
+ for notification_id in notification_ids:
138
+ NotificationDelivery.queue_delivery(
139
+ background_tasks=background_tasks,
140
+ notification_id=notification_id
141
+ )
142
+ logger.debug(f"Queued {len(notification_ids)} notifications for delivery")
143
+
144
+ @staticmethod
145
+ def _deliver_notification(notification_id: UUID) -> None:
146
+ """
147
+ Internal method to deliver a notification.
148
+ Runs in background task.
149
+
150
+ Args:
151
+ notification_id: ID of notification to deliver
152
+ """
153
+ db = SessionLocal()
154
+
155
+ try:
156
+ # Get notification
157
+ notification = db.query(Notification).filter(
158
+ Notification.id == notification_id
159
+ ).first()
160
+
161
+ if not notification:
162
+ logger.error(f"Notification {notification_id} not found")
163
+ return
164
+
165
+ # Check if channel is enabled
166
+ if not NotificationDeliveryConfig.is_channel_enabled(notification.channel):
167
+ logger.info(
168
+ f"Channel {notification.channel.value} is disabled. "
169
+ f"Marking notification {notification_id} as skipped."
170
+ )
171
+ notification.status = NotificationStatus.SENT # Mark as sent to avoid retry
172
+ notification.additional_metadata = notification.additional_metadata or {}
173
+ notification.additional_metadata["delivery_skipped"] = True
174
+ notification.additional_metadata["skip_reason"] = "Channel disabled for MVP"
175
+ db.commit()
176
+ return
177
+
178
+ # In-app notifications don't need external delivery
179
+ if notification.channel == NotificationChannel.IN_APP:
180
+ notification.status = NotificationStatus.SENT
181
+ db.commit()
182
+ logger.debug(f"In-app notification {notification_id} marked as sent")
183
+ return
184
+
185
+ # Deliver via external channel
186
+ if notification.channel == NotificationChannel.WHATSAPP:
187
+ NotificationDelivery._deliver_whatsapp(db, notification)
188
+ elif notification.channel == NotificationChannel.EMAIL:
189
+ NotificationDelivery._deliver_email(db, notification)
190
+ elif notification.channel == NotificationChannel.SMS:
191
+ NotificationDelivery._deliver_sms(db, notification)
192
+ elif notification.channel == NotificationChannel.PUSH:
193
+ NotificationDelivery._deliver_push(db, notification)
194
+ else:
195
+ logger.warning(f"Unknown channel: {notification.channel}")
196
+ notification.status = NotificationStatus.FAILED
197
+ notification.error_message = f"Unknown channel: {notification.channel}"
198
+ db.commit()
199
+
200
+ except Exception as e:
201
+ logger.error(
202
+ f"Failed to deliver notification {notification_id}: {str(e)}",
203
+ exc_info=True
204
+ )
205
+ # Don't raise - background task should not fail
206
+
207
+ finally:
208
+ db.close()
209
+
210
+ @staticmethod
211
+ def _deliver_whatsapp(db, notification: Notification) -> None:
212
+ """
213
+ Deliver notification via WhatsApp.
214
+
215
+ For MVP: This is a placeholder. When ready to integrate:
216
+ 1. Set ENABLE_WHATSAPP = True in config
217
+ 2. Implement actual WhatsApp API call (Twilio, Africa's Talking, etc.)
218
+ 3. Handle API errors and retries
219
+ """
220
+ try:
221
+ # TODO: Implement WhatsApp delivery
222
+ # Example:
223
+ # from app.services.whatsapp_service import WhatsAppService
224
+ # WhatsAppService.send_message(
225
+ # phone_number=notification.user.phone_number,
226
+ # message=notification.message
227
+ # )
228
+
229
+ # For now, just log
230
+ logger.info(
231
+ f"WhatsApp delivery placeholder for notification {notification.id}. "
232
+ f"Implement WhatsAppService when ready."
233
+ )
234
+
235
+ notification.status = NotificationStatus.SENT
236
+ notification.sent_at = notification.sent_at or notification.created_at
237
+ db.commit()
238
+
239
+ except Exception as e:
240
+ logger.error(f"WhatsApp delivery failed: {str(e)}", exc_info=True)
241
+ notification.status = NotificationStatus.FAILED
242
+ notification.error_message = str(e)
243
+ db.commit()
244
+
245
+ @staticmethod
246
+ def _deliver_email(db, notification: Notification) -> None:
247
+ """
248
+ Deliver notification via Email.
249
+
250
+ For MVP: This is a placeholder. When ready to integrate:
251
+ 1. Set ENABLE_EMAIL = True in config
252
+ 2. Implement actual Email sending (SendGrid, AWS SES, etc.)
253
+ 3. Handle API errors and retries
254
+ """
255
+ try:
256
+ # TODO: Implement Email delivery
257
+ # Example:
258
+ # from app.services.email_service import EmailService
259
+ # EmailService.send_email(
260
+ # to_email=notification.user.email,
261
+ # subject=notification.title,
262
+ # body=notification.message
263
+ # )
264
+
265
+ logger.info(
266
+ f"Email delivery placeholder for notification {notification.id}. "
267
+ f"Implement EmailService when ready."
268
+ )
269
+
270
+ notification.status = NotificationStatus.SENT
271
+ notification.sent_at = notification.sent_at or notification.created_at
272
+ db.commit()
273
+
274
+ except Exception as e:
275
+ logger.error(f"Email delivery failed: {str(e)}", exc_info=True)
276
+ notification.status = NotificationStatus.FAILED
277
+ notification.error_message = str(e)
278
+ db.commit()
279
+
280
+ @staticmethod
281
+ def _deliver_sms(db, notification: Notification) -> None:
282
+ """
283
+ Deliver notification via SMS.
284
+
285
+ For MVP: This is a placeholder. When ready to integrate:
286
+ 1. Set ENABLE_SMS = True in config
287
+ 2. Implement actual SMS sending (Twilio, Africa's Talking, etc.)
288
+ 3. Handle API errors and retries
289
+ """
290
+ try:
291
+ # TODO: Implement SMS delivery
292
+ logger.info(
293
+ f"SMS delivery placeholder for notification {notification.id}. "
294
+ f"Implement SMSService when ready."
295
+ )
296
+
297
+ notification.status = NotificationStatus.SENT
298
+ notification.sent_at = notification.sent_at or notification.created_at
299
+ db.commit()
300
+
301
+ except Exception as e:
302
+ logger.error(f"SMS delivery failed: {str(e)}", exc_info=True)
303
+ notification.status = NotificationStatus.FAILED
304
+ notification.error_message = str(e)
305
+ db.commit()
306
+
307
+ @staticmethod
308
+ def _deliver_push(db, notification: Notification) -> None:
309
+ """
310
+ Deliver notification via Push Notification.
311
+
312
+ For MVP: This is a placeholder. When ready to integrate:
313
+ 1. Set ENABLE_PUSH = True in config
314
+ 2. Implement actual Push notification (Firebase, OneSignal, etc.)
315
+ 3. Handle API errors and retries
316
+ """
317
+ try:
318
+ # TODO: Implement Push notification delivery
319
+ logger.info(
320
+ f"Push notification delivery placeholder for notification {notification.id}. "
321
+ f"Implement PushService when ready."
322
+ )
323
+
324
+ notification.status = NotificationStatus.SENT
325
+ notification.sent_at = notification.sent_at or notification.created_at
326
+ db.commit()
327
+
328
+ except Exception as e:
329
+ logger.error(f"Push notification delivery failed: {str(e)}", exc_info=True)
330
+ notification.status = NotificationStatus.FAILED
331
+ notification.error_message = str(e)
332
+ db.commit()
333
+
src/app/services/notification_helper.py CHANGED
@@ -1133,3 +1133,235 @@ class NotificationHelper:
1133
  )
1134
 
1135
  logger.info(f"Created notifications for {len(user_ids)} users with roles {roles} in project {project_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1133
  )
1134
 
1135
  logger.info(f"Created notifications for {len(user_ids)} users with roles {roles} in project {project_id}")
1136
+
1137
+ # ============================================
1138
+ # PAYROLL NOTIFICATIONS
1139
+ # ============================================
1140
+
1141
+ @staticmethod
1142
+ async def notify_payroll_exported(
1143
+ db: Session,
1144
+ exported_by: User,
1145
+ payroll_records: List,
1146
+ total_amount: float,
1147
+ total_days_worked: int,
1148
+ total_tickets_closed: int,
1149
+ period_start: str,
1150
+ period_end: str,
1151
+ project_id: Optional[UUID] = None,
1152
+ warnings: Optional[List[str]] = None
1153
+ ):
1154
+ """
1155
+ Notify manager/admin who exported payroll with summary.
1156
+
1157
+ Args:
1158
+ db: Database session
1159
+ exported_by: User who initiated the export
1160
+ payroll_records: List of UserPayroll objects that were exported
1161
+ total_amount: Total payroll amount exported
1162
+ total_days_worked: Total days worked across all payroll
1163
+ total_tickets_closed: Total tickets closed across all payroll
1164
+ period_start: Period start date (ISO format)
1165
+ period_end: Period end date (ISO format)
1166
+ project_id: Optional project ID
1167
+ warnings: Optional list of warnings from export
1168
+ """
1169
+ service = NotificationService()
1170
+
1171
+ # Build summary message
1172
+ payroll_count = len(payroll_records)
1173
+ payroll_ids = [str(p.id) for p in payroll_records]
1174
+
1175
+ title = f"Payroll Export Complete: {payroll_count} records"
1176
+
1177
+ message = (
1178
+ f"Successfully exported payroll for period {period_start} to {period_end}.\n\n"
1179
+ f"📊 Summary:\n"
1180
+ f"• {payroll_count} workers paid\n"
1181
+ f"• {total_days_worked} total days worked\n"
1182
+ f"• {total_tickets_closed} total tickets closed\n"
1183
+ f"• {total_amount:,.2f} KES total payout"
1184
+ )
1185
+
1186
+ if warnings:
1187
+ message += f"\n\n⚠️ {len(warnings)} warnings - review CSV before uploading to Tende Pay"
1188
+
1189
+ metadata = {
1190
+ 'project_id': str(project_id) if project_id else None,
1191
+ 'payroll_ids': payroll_ids,
1192
+ 'payroll_count': payroll_count,
1193
+ 'total_amount': total_amount,
1194
+ 'total_days_worked': total_days_worked,
1195
+ 'total_tickets_closed': total_tickets_closed,
1196
+ 'period_start': period_start,
1197
+ 'period_end': period_end,
1198
+ 'warning_count': len(warnings) if warnings else 0,
1199
+ 'action_url': f'/projects/{project_id}/payroll' if project_id else '/payroll',
1200
+ 'export_timestamp': datetime.utcnow().isoformat()
1201
+ }
1202
+
1203
+ await service.create_notification(
1204
+ db=db,
1205
+ user_id=exported_by.id,
1206
+ title=title,
1207
+ message=message,
1208
+ source_type='payroll',
1209
+ source_id=None, # Bulk operation, no single source
1210
+ notification_type='payroll_exported',
1211
+ channel='in_app',
1212
+ project_id=project_id,
1213
+ additional_metadata=metadata
1214
+ )
1215
+
1216
+ logger.info(f"Created payroll export notification for user {exported_by.id}")
1217
+
1218
+ @staticmethod
1219
+ async def notify_payroll_payment(
1220
+ db: Session,
1221
+ payroll,
1222
+ user: User,
1223
+ project_id: Optional[UUID] = None
1224
+ ):
1225
+ """
1226
+ Notify worker that their payroll has been exported for payment.
1227
+
1228
+ Args:
1229
+ db: Database session
1230
+ payroll: UserPayroll object
1231
+ user: User receiving payment
1232
+ project_id: Optional project ID
1233
+ """
1234
+ service = NotificationService()
1235
+
1236
+ # Build payment details message
1237
+ title = f"💰 Payment Processed: {payroll.total_amount:,.2f} KES"
1238
+
1239
+ # Build breakdown
1240
+ breakdown_parts = []
1241
+ if payroll.days_worked:
1242
+ breakdown_parts.append(f"{payroll.days_worked} days worked")
1243
+ if payroll.tickets_closed:
1244
+ breakdown_parts.append(f"{payroll.tickets_closed} tickets closed")
1245
+
1246
+ breakdown = ", ".join(breakdown_parts) if breakdown_parts else "work completed"
1247
+
1248
+ message = (
1249
+ f"Your payment for {payroll.period_start_date.strftime('%b %d')} to "
1250
+ f"{payroll.period_end_date.strftime('%b %d, %Y')} has been processed.\n\n"
1251
+ f"📋 Work Summary:\n"
1252
+ f"• {breakdown}\n"
1253
+ f"• Base earnings: {payroll.base_earnings:,.2f} KES\n"
1254
+ )
1255
+
1256
+ if payroll.bonus_amount and payroll.bonus_amount > 0:
1257
+ message += f"• Bonus: {payroll.bonus_amount:,.2f} KES\n"
1258
+
1259
+ if payroll.deductions and payroll.deductions > 0:
1260
+ message += f"• Deductions: -{payroll.deductions:,.2f} KES\n"
1261
+
1262
+ message += f"\n💵 Total Payment: {payroll.total_amount:,.2f} KES"
1263
+ message += f"\n\nPayment will be sent to your registered account shortly."
1264
+
1265
+ metadata = {
1266
+ 'project_id': str(project_id) if project_id else None,
1267
+ 'payroll_id': str(payroll.id),
1268
+ 'period_start': payroll.period_start_date.isoformat(),
1269
+ 'period_end': payroll.period_end_date.isoformat(),
1270
+ 'days_worked': payroll.days_worked,
1271
+ 'tickets_closed': payroll.tickets_closed,
1272
+ 'base_earnings': float(payroll.base_earnings),
1273
+ 'bonus_amount': float(payroll.bonus_amount) if payroll.bonus_amount else 0,
1274
+ 'deductions': float(payroll.deductions) if payroll.deductions else 0,
1275
+ 'total_amount': float(payroll.total_amount),
1276
+ 'action_url': f'/payroll/{payroll.id}',
1277
+ 'payment_method': payroll.payment_method
1278
+ }
1279
+
1280
+ # Send via WhatsApp for important payment notifications
1281
+ await service.create_notification(
1282
+ db=db,
1283
+ user_id=user.id,
1284
+ title=title,
1285
+ message=message,
1286
+ source_type='payroll',
1287
+ source_id=payroll.id,
1288
+ notification_type='payment',
1289
+ channel='whatsapp', # Use WhatsApp for payment notifications
1290
+ project_id=project_id,
1291
+ additional_metadata=metadata,
1292
+ send_now=True # Send immediately
1293
+ )
1294
+
1295
+ logger.info(f"Created payment notification for user {user.id}, payroll {payroll.id}")
1296
+
1297
+ # ============================================
1298
+ # TICKET DROP NOTIFICATIONS
1299
+ # ============================================
1300
+
1301
+ @staticmethod
1302
+ async def notify_ticket_dropped(
1303
+ db: Session,
1304
+ ticket,
1305
+ assignment,
1306
+ dropped_by: User,
1307
+ drop_type: str,
1308
+ reason: str
1309
+ ):
1310
+ """
1311
+ Notify managers when agent drops a ticket (goes to PENDING_REVIEW).
1312
+ Critical notification - ticket needs immediate manager attention.
1313
+
1314
+ Args:
1315
+ db: Database session
1316
+ ticket: Ticket that was dropped
1317
+ assignment: TicketAssignment that was dropped
1318
+ dropped_by: User who dropped the ticket
1319
+ drop_type: Type of drop (reschedule, equipment_issue, customer_issue, etc.)
1320
+ reason: Reason for dropping
1321
+ """
1322
+ service = NotificationService()
1323
+
1324
+ # Build notification message
1325
+ title = f"⚠️ Ticket Dropped - Action Required"
1326
+
1327
+ # Format drop type for display
1328
+ drop_type_display = drop_type.replace('_', ' ').title()
1329
+
1330
+ message = (
1331
+ f"{dropped_by.name} dropped ticket: {ticket.ticket_name or ticket.ticket_type}\n\n"
1332
+ f"🔴 Reason: {drop_type_display}\n"
1333
+ f"💬 Details: {reason}\n\n"
1334
+ f"⚡ This ticket is now in PENDING REVIEW and needs your immediate attention."
1335
+ )
1336
+
1337
+ metadata = {
1338
+ 'project_id': str(ticket.project_id),
1339
+ 'ticket_id': str(ticket.id),
1340
+ 'assignment_id': str(assignment.id),
1341
+ 'dropped_by_user_id': str(dropped_by.id),
1342
+ 'dropped_by_name': dropped_by.name,
1343
+ 'drop_type': drop_type,
1344
+ 'reason': reason,
1345
+ 'ticket_name': ticket.ticket_name,
1346
+ 'ticket_type': ticket.ticket_type,
1347
+ 'ticket_status': ticket.status,
1348
+ 'action_url': f'/projects/{ticket.project_id}/tickets/{ticket.id}',
1349
+ 'requires_action': True,
1350
+ 'priority': 'high'
1351
+ }
1352
+
1353
+ # Notify all managers and dispatchers in the project
1354
+ await NotificationHelper.notify_users_by_role(
1355
+ db=db,
1356
+ project_id=ticket.project_id,
1357
+ roles=[AppRole.PROJECT_MANAGER, AppRole.DISPATCHER],
1358
+ title=title,
1359
+ message=message,
1360
+ source_type='ticket',
1361
+ source_id=ticket.id,
1362
+ notification_type='ticket_dropped',
1363
+ channel='in_app', # Could also use WhatsApp for urgent drops
1364
+ additional_metadata=metadata
1365
+ )
1366
+
1367
+ logger.info(f"Created ticket drop notification for ticket {ticket.id}, dropped by {dropped_by.id}")
src/app/services/payroll_service.py CHANGED
@@ -172,9 +172,9 @@ class PayrollService:
172
  ) -> Dict[str, Any]:
173
  """
174
  Get compensation rates from project_team and project_role
175
-
176
  Returns:
177
- Dict with flat_rate, commission_percentage, base_amount, hourly_rate
178
  """
179
  try:
180
  # Get project team member with role
@@ -185,34 +185,38 @@ class PayrollService:
185
  ProjectTeam.project_id == project_id,
186
  ProjectTeam.deleted_at.is_(None)
187
  ).first()
188
-
189
  if not team_member:
190
  logger.warning(f"No project team record found for user {user_id} in project {project_id}")
191
  return {
192
- "flat_rate": Decimal('0'),
 
 
 
193
  "commission_percentage": Decimal('0'),
194
- "base_amount": Decimal('0'),
195
- "hourly_rate": Decimal('0'),
196
- "project_team_id": None
197
  }
198
-
199
  role = team_member.project_role
200
  if not role:
201
  logger.warning(f"No role assigned for user {user_id} in project {project_id}")
202
  return {
203
- "flat_rate": Decimal('0'),
 
 
 
204
  "commission_percentage": Decimal('0'),
205
- "base_amount": Decimal('0'),
206
- "hourly_rate": Decimal('0'),
207
- "project_team_id": team_member.id
208
  }
209
-
210
  return {
211
- "flat_rate": Decimal(str(role.flat_rate_amount or 0)),
 
 
 
212
  "commission_percentage": Decimal(str(role.commission_percentage or 0)),
213
- "base_amount": Decimal(str(role.base_amount or 0)),
214
- "hourly_rate": Decimal(str(role.hourly_rate or 0)),
215
- "bonus_percentage": Decimal(str(role.bonus_percentage or 0)),
216
  "project_team_id": team_member.id,
217
  "role_name": role.role_name
218
  }
@@ -224,22 +228,82 @@ class PayrollService:
224
  )
225
 
226
  @staticmethod
227
- def calculate_ticket_earnings(
 
 
228
  tickets_closed: int,
229
- base_amount: Decimal,
230
- commission_percentage: Decimal
231
- ) -> Decimal:
 
 
 
232
  """
233
- Calculate earnings from tickets closed
234
-
235
- Formula: (base_amount * tickets_closed) + (base_amount * tickets_closed * commission_percentage / 100)
 
236
  """
237
- if tickets_closed == 0:
238
- return Decimal('0')
239
-
240
- base_earnings = base_amount * Decimal(tickets_closed)
241
- commission = base_earnings * (commission_percentage / Decimal('100'))
242
- return base_earnings + commission
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
  # ============================================
245
  # PAYROLL GENERATION
@@ -314,15 +378,19 @@ class PayrollService:
314
  )
315
 
316
  compensation = PayrollService.get_compensation_rates(db, user_id, project_id)
317
-
318
- # Calculate earnings
319
- flat_rate = compensation["flat_rate"]
320
- ticket_earnings = PayrollService.calculate_ticket_earnings(
321
- aggregated_data["tickets_closed"],
322
- compensation["base_amount"],
323
- compensation["commission_percentage"]
 
 
 
 
324
  )
325
-
326
  # Create payroll record
327
  payroll = UserPayroll(
328
  user_id=user_id,
@@ -331,15 +399,14 @@ class PayrollService:
331
  period_start_date=period_start_date,
332
  period_end_date=period_end_date,
333
  tickets_closed=aggregated_data["tickets_closed"],
334
- hours_worked=aggregated_data["hours_worked"],
335
  days_worked=aggregated_data["days_worked"],
336
- flat_rate_amount=flat_rate,
337
- ticket_earnings=ticket_earnings,
338
  bonus_amount=Decimal('0'), # Can be added manually later
339
  deductions=Decimal('0'), # Can be added manually later
340
  total_amount=Decimal('0'), # Will be calculated
341
  calculation_notes=f"Auto-generated for week {period_start_date} to {period_end_date}. "
342
  f"Role: {compensation.get('role_name', 'N/A')}. "
 
343
  f"Tickets: {aggregated_data['tickets_completed']} completed, "
344
  f"{aggregated_data['tickets_assigned']} assigned, "
345
  f"{aggregated_data['tickets_rejected']} rejected. "
@@ -537,25 +604,28 @@ class PayrollService:
537
  compensation = PayrollService.get_compensation_rates(
538
  db, payroll.user_id, payroll.project_id
539
  )
540
-
541
- # Calculate new earnings
542
- flat_rate = compensation["flat_rate"]
543
- ticket_earnings = PayrollService.calculate_ticket_earnings(
544
- aggregated_data["tickets_closed"],
545
- compensation["base_amount"],
546
- compensation["commission_percentage"]
 
 
 
 
547
  )
548
-
549
  # Recalculate (preserves manually added bonuses/deductions)
550
  payroll.recalculate_from_data(
551
  tickets_closed=aggregated_data["tickets_closed"],
552
- hours_worked=aggregated_data["hours_worked"],
553
  days_worked=aggregated_data["days_worked"],
554
- flat_rate=flat_rate,
555
- ticket_earnings=ticket_earnings,
556
  bonus=Decimal(str(payroll.bonus_amount or 0)), # Keep existing bonus
557
  deductions=Decimal(str(payroll.deductions or 0)), # Keep existing deductions
558
  calculation_notes=f"Recalculated at {datetime.utcnow().isoformat()} due to timesheet correction. "
 
559
  f"Tickets: {aggregated_data['tickets_completed']} completed, "
560
  f"{aggregated_data['tickets_assigned']} assigned."
561
  )
@@ -738,10 +808,8 @@ class PayrollService:
738
  period_end_date=payroll.period_end_date,
739
  period_label=payroll.period_label,
740
  tickets_closed=payroll.tickets_closed,
741
- hours_worked=payroll.hours_worked,
742
  days_worked=payroll.days_worked,
743
- flat_rate_amount=payroll.flat_rate_amount,
744
- ticket_earnings=payroll.ticket_earnings,
745
  bonus_amount=payroll.bonus_amount,
746
  deductions=payroll.deductions,
747
  total_amount=payroll.total_amount,
@@ -763,3 +831,247 @@ class PayrollService:
763
  project_name=project.title if project else None,
764
  paid_by_name=f"{paid_by.first_name} {paid_by.last_name}" if paid_by else None
765
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  ) -> Dict[str, Any]:
173
  """
174
  Get compensation rates from project_team and project_role
175
+
176
  Returns:
177
+ Dict with compensation_type, base_rate, rate_period, per_unit_rate, commission_percentage
178
  """
179
  try:
180
  # Get project team member with role
 
185
  ProjectTeam.project_id == project_id,
186
  ProjectTeam.deleted_at.is_(None)
187
  ).first()
188
+
189
  if not team_member:
190
  logger.warning(f"No project team record found for user {user_id} in project {project_id}")
191
  return {
192
+ "compensation_type": None,
193
+ "base_rate": Decimal('0'),
194
+ "rate_period": None,
195
+ "per_unit_rate": Decimal('0'),
196
  "commission_percentage": Decimal('0'),
197
+ "project_team_id": None,
198
+ "role_name": None
 
199
  }
200
+
201
  role = team_member.project_role
202
  if not role:
203
  logger.warning(f"No role assigned for user {user_id} in project {project_id}")
204
  return {
205
+ "compensation_type": None,
206
+ "base_rate": Decimal('0'),
207
+ "rate_period": None,
208
+ "per_unit_rate": Decimal('0'),
209
  "commission_percentage": Decimal('0'),
210
+ "project_team_id": team_member.id,
211
+ "role_name": None
 
212
  }
213
+
214
  return {
215
+ "compensation_type": role.compensation_type,
216
+ "base_rate": Decimal(str(role.base_rate or 0)),
217
+ "rate_period": role.rate_period,
218
+ "per_unit_rate": Decimal(str(role.per_unit_rate or 0)),
219
  "commission_percentage": Decimal(str(role.commission_percentage or 0)),
 
 
 
220
  "project_team_id": team_member.id,
221
  "role_name": role.role_name
222
  }
 
228
  )
229
 
230
  @staticmethod
231
+ def calculate_base_earnings(
232
+ compensation_type: str,
233
+ days_worked: int,
234
  tickets_closed: int,
235
+ base_rate: Decimal,
236
+ rate_period: Optional[str],
237
+ per_unit_rate: Decimal,
238
+ commission_percentage: Decimal,
239
+ ticket_value_total: Decimal = Decimal('0')
240
+ ) -> tuple[Decimal, str]:
241
  """
242
+ Calculate base earnings based on compensation type
243
+
244
+ Returns:
245
+ Tuple of (base_earnings, calculation_notes)
246
  """
247
+ if not compensation_type:
248
+ return Decimal('0'), "No compensation type defined"
249
+
250
+ if compensation_type == "FIXED_RATE":
251
+ # Time-based pay
252
+ if rate_period == "DAY":
253
+ earnings = base_rate * Decimal(days_worked)
254
+ notes = f"FIXED_RATE: {days_worked} days × {base_rate} KES/day = {earnings} KES"
255
+ elif rate_period == "WEEK":
256
+ # Weekly rate is flat regardless of days worked
257
+ earnings = base_rate
258
+ notes = f"FIXED_RATE: {base_rate} KES/week (flat)"
259
+ elif rate_period == "MONTH":
260
+ # Monthly rate is flat
261
+ earnings = base_rate
262
+ notes = f"FIXED_RATE: {base_rate} KES/month (flat)"
263
+ elif rate_period == "HOUR":
264
+ # Would need hours_worked from timesheets
265
+ # For now, assume 8 hours per day
266
+ hours = days_worked * 8
267
+ earnings = base_rate * Decimal(hours)
268
+ notes = f"FIXED_RATE: {hours} hours × {base_rate} KES/hour = {earnings} KES"
269
+ else:
270
+ earnings = Decimal('0')
271
+ notes = f"FIXED_RATE: Unknown rate_period '{rate_period}'"
272
+ return earnings, notes
273
+
274
+ elif compensation_type == "PER_UNIT":
275
+ # Work-based pay
276
+ earnings = per_unit_rate * Decimal(tickets_closed)
277
+ notes = f"PER_UNIT: {tickets_closed} tickets × {per_unit_rate} KES/ticket = {earnings} KES"
278
+ return earnings, notes
279
+
280
+ elif compensation_type == "COMMISSION":
281
+ # Percentage-based pay
282
+ earnings = ticket_value_total * (commission_percentage / Decimal('100'))
283
+ notes = f"COMMISSION: {ticket_value_total} KES × {commission_percentage}% = {earnings} KES"
284
+ return earnings, notes
285
+
286
+ elif compensation_type == "FIXED_PLUS_COMMISSION":
287
+ # Hybrid: base + commission
288
+ if rate_period == "DAY":
289
+ base_earnings = base_rate * Decimal(days_worked)
290
+ elif rate_period == "WEEK":
291
+ base_earnings = base_rate
292
+ elif rate_period == "MONTH":
293
+ base_earnings = base_rate
294
+ elif rate_period == "HOUR":
295
+ hours = days_worked * 8
296
+ base_earnings = base_rate * Decimal(hours)
297
+ else:
298
+ base_earnings = Decimal('0')
299
+
300
+ commission_earnings = ticket_value_total * (commission_percentage / Decimal('100'))
301
+ earnings = base_earnings + commission_earnings
302
+ notes = f"FIXED_PLUS_COMMISSION: Base {base_earnings} KES + Commission {commission_earnings} KES = {earnings} KES"
303
+ return earnings, notes
304
+
305
+ else:
306
+ return Decimal('0'), f"Unknown compensation type: {compensation_type}"
307
 
308
  # ============================================
309
  # PAYROLL GENERATION
 
378
  )
379
 
380
  compensation = PayrollService.get_compensation_rates(db, user_id, project_id)
381
+
382
+ # Calculate base earnings
383
+ base_earnings, calc_notes = PayrollService.calculate_base_earnings(
384
+ compensation_type=compensation["compensation_type"],
385
+ days_worked=aggregated_data["days_worked"],
386
+ tickets_closed=aggregated_data["tickets_closed"],
387
+ base_rate=compensation["base_rate"],
388
+ rate_period=compensation["rate_period"],
389
+ per_unit_rate=compensation["per_unit_rate"],
390
+ commission_percentage=compensation["commission_percentage"],
391
+ ticket_value_total=Decimal('0') # TODO: Add ticket value tracking if needed
392
  )
393
+
394
  # Create payroll record
395
  payroll = UserPayroll(
396
  user_id=user_id,
 
399
  period_start_date=period_start_date,
400
  period_end_date=period_end_date,
401
  tickets_closed=aggregated_data["tickets_closed"],
 
402
  days_worked=aggregated_data["days_worked"],
403
+ base_earnings=base_earnings,
 
404
  bonus_amount=Decimal('0'), # Can be added manually later
405
  deductions=Decimal('0'), # Can be added manually later
406
  total_amount=Decimal('0'), # Will be calculated
407
  calculation_notes=f"Auto-generated for week {period_start_date} to {period_end_date}. "
408
  f"Role: {compensation.get('role_name', 'N/A')}. "
409
+ f"{calc_notes}. "
410
  f"Tickets: {aggregated_data['tickets_completed']} completed, "
411
  f"{aggregated_data['tickets_assigned']} assigned, "
412
  f"{aggregated_data['tickets_rejected']} rejected. "
 
604
  compensation = PayrollService.get_compensation_rates(
605
  db, payroll.user_id, payroll.project_id
606
  )
607
+
608
+ # Calculate new base earnings
609
+ base_earnings, calc_notes = PayrollService.calculate_base_earnings(
610
+ compensation_type=compensation["compensation_type"],
611
+ days_worked=aggregated_data["days_worked"],
612
+ tickets_closed=aggregated_data["tickets_closed"],
613
+ base_rate=compensation["base_rate"],
614
+ rate_period=compensation["rate_period"],
615
+ per_unit_rate=compensation["per_unit_rate"],
616
+ commission_percentage=compensation["commission_percentage"],
617
+ ticket_value_total=Decimal('0') # TODO: Add ticket value tracking if needed
618
  )
619
+
620
  # Recalculate (preserves manually added bonuses/deductions)
621
  payroll.recalculate_from_data(
622
  tickets_closed=aggregated_data["tickets_closed"],
 
623
  days_worked=aggregated_data["days_worked"],
624
+ base_earnings=base_earnings,
 
625
  bonus=Decimal(str(payroll.bonus_amount or 0)), # Keep existing bonus
626
  deductions=Decimal(str(payroll.deductions or 0)), # Keep existing deductions
627
  calculation_notes=f"Recalculated at {datetime.utcnow().isoformat()} due to timesheet correction. "
628
+ f"{calc_notes}. "
629
  f"Tickets: {aggregated_data['tickets_completed']} completed, "
630
  f"{aggregated_data['tickets_assigned']} assigned."
631
  )
 
808
  period_end_date=payroll.period_end_date,
809
  period_label=payroll.period_label,
810
  tickets_closed=payroll.tickets_closed,
 
811
  days_worked=payroll.days_worked,
812
+ base_earnings=payroll.base_earnings,
 
813
  bonus_amount=payroll.bonus_amount,
814
  deductions=payroll.deductions,
815
  total_amount=payroll.total_amount,
 
831
  project_name=project.title if project else None,
832
  paid_by_name=f"{paid_by.first_name} {paid_by.last_name}" if paid_by else None
833
  )
834
+
835
+ # ============================================
836
+ # PAYROLL EXPORT FOR PAYMENT
837
+ # ============================================
838
+
839
+ @staticmethod
840
+ def export_for_payment(
841
+ db: Session,
842
+ from_date: date,
843
+ to_date: date,
844
+ current_user: User,
845
+ project_id: Optional[UUID] = None,
846
+ user_id: Optional[UUID] = None
847
+ ) -> Tuple[List[dict], List[str], List]:
848
+ """
849
+ Export unpaid payroll records in Tende Pay CSV format.
850
+ Marks all exported payroll as paid.
851
+
852
+ Args:
853
+ db: Database session
854
+ from_date: Start date (inclusive) - period_start_date
855
+ to_date: End date (inclusive) - period_end_date
856
+ current_user: User performing export (PM/Admin)
857
+ project_id: Optional project filter
858
+ user_id: Optional user filter
859
+
860
+ Returns:
861
+ Tuple of (csv_rows, warnings, exported_payroll_records)
862
+ - csv_rows: List of dicts with Tende Pay CSV columns
863
+ - warnings: List of warning messages
864
+ - exported_payroll_records: List of UserPayroll objects that were exported
865
+
866
+ Raises:
867
+ HTTPException: If user lacks permission
868
+ """
869
+ from app.models.user_financial_account import UserFinancialAccount
870
+ from app.services.tende_pay_formatter import TendePayFormatter
871
+ from collections import defaultdict
872
+
873
+ # Permission check
874
+ if not PayrollService.can_user_manage_payroll(current_user, project_id):
875
+ raise HTTPException(
876
+ status_code=status.HTTP_403_FORBIDDEN,
877
+ detail="You don't have permission to export payroll"
878
+ )
879
+
880
+ # Build query for unpaid payroll
881
+ query = db.query(UserPayroll).filter(
882
+ UserPayroll.is_paid == False,
883
+ UserPayroll.deleted_at == None,
884
+ UserPayroll.period_start_date >= from_date,
885
+ UserPayroll.period_end_date <= to_date
886
+ )
887
+
888
+ # Apply filters
889
+ if project_id:
890
+ query = query.filter(UserPayroll.project_id == project_id)
891
+ if user_id:
892
+ query = query.filter(UserPayroll.user_id == user_id)
893
+
894
+ # Get payroll records
895
+ payroll_records = query.order_by(
896
+ UserPayroll.user_id,
897
+ UserPayroll.period_start_date
898
+ ).all()
899
+
900
+ if not payroll_records:
901
+ logger.info(f"No unpaid payroll found for period {from_date} to {to_date}")
902
+ return [], []
903
+
904
+ logger.info(f"Found {len(payroll_records)} unpaid payroll records to export")
905
+
906
+ # Build Tende Pay CSV rows
907
+ csv_rows = []
908
+ warnings = []
909
+ exported_payroll_ids = set() # Track which payroll was successfully exported
910
+ payment_reference = f"PAYROLL_EXPORT_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}_{current_user.id}"
911
+
912
+ for payroll in payroll_records:
913
+ # Get user
914
+ user = db.query(User).filter(User.id == payroll.user_id).first()
915
+ if not user:
916
+ warnings.append(f"Payroll {payroll.id}: User not found - SKIPPED")
917
+ continue
918
+
919
+ # Get user's primary financial account (most recent version at export time)
920
+ financial_account = db.query(UserFinancialAccount).filter(
921
+ UserFinancialAccount.user_id == user.id,
922
+ UserFinancialAccount.is_primary == True,
923
+ UserFinancialAccount.is_active == True,
924
+ UserFinancialAccount.deleted_at == None
925
+ ).first()
926
+
927
+ if not financial_account:
928
+ # WARNING but still export with empty payment details
929
+ warnings.append(
930
+ f"{user.name}: No active primary financial account - "
931
+ f"exported with empty payment details. Please add payment details."
932
+ )
933
+ # Create minimal row with warning
934
+ csv_rows.append({
935
+ "NAME": user.name,
936
+ "ID NUMBER": user.id_number or "",
937
+ "PHONE NUMBER": "",
938
+ "AMOUNT": float(payroll.total_amount),
939
+ "PAYMENT MODE": "",
940
+ "BANK (Optional)": "",
941
+ "BANK ACCOUNT NO (Optional)": "",
942
+ "PAYBILL BUSINESS NO (Optional)": "",
943
+ "PAYBILL ACCOUNT NO (Optional)": "",
944
+ "BUY GOODS TILL NO (Optional)": "",
945
+ "BILL PAYMENT BILLER CODE (Optional)": "",
946
+ "BILL PAYMENT ACCOUNT NO (Optional)": "",
947
+ "NARRATION (OPTIONAL)": TendePayFormatter.build_payroll_narration(payroll)
948
+ })
949
+ exported_payroll_ids.add(payroll.id)
950
+ continue
951
+
952
+ # Map payout_method to payment_method
953
+ payment_method = financial_account.payout_method
954
+
955
+ # Build payment_details dict from financial account
956
+ payment_details = {}
957
+
958
+ if payment_method == "mobile_money":
959
+ # Map to send_money for TendePayFormatter
960
+ payment_method = "send_money"
961
+ payment_details = {
962
+ "phone_number": financial_account.mobile_money_phone,
963
+ "recipient_name": financial_account.mobile_money_account_name or user.name
964
+ }
965
+ elif payment_method == "bank_transfer":
966
+ payment_details = {
967
+ "bank_name": financial_account.bank_name,
968
+ "account_number": financial_account.bank_account_number,
969
+ "account_name": financial_account.bank_account_name or user.name,
970
+ "branch": financial_account.bank_branch
971
+ }
972
+ else:
973
+ # WARNING but still export with empty payment details
974
+ warnings.append(
975
+ f"{user.name}: Unsupported payout method '{payment_method}' - "
976
+ f"exported with empty payment details. Supported: mobile_money, bank_transfer."
977
+ )
978
+ csv_rows.append({
979
+ "NAME": user.name,
980
+ "ID NUMBER": user.id_number or "",
981
+ "PHONE NUMBER": "",
982
+ "AMOUNT": float(payroll.total_amount),
983
+ "PAYMENT MODE": "",
984
+ "BANK (Optional)": "",
985
+ "BANK ACCOUNT NO (Optional)": "",
986
+ "PAYBILL BUSINESS NO (Optional)": "",
987
+ "PAYBILL ACCOUNT NO (Optional)": "",
988
+ "BUY GOODS TILL NO (Optional)": "",
989
+ "BILL PAYMENT BILLER CODE (Optional)": "",
990
+ "BILL PAYMENT ACCOUNT NO (Optional)": "",
991
+ "NARRATION (OPTIONAL)": TendePayFormatter.build_payroll_narration(payroll)
992
+ })
993
+ exported_payroll_ids.add(payroll.id)
994
+ continue
995
+
996
+ # Validate payment details - generate warnings but don't skip
997
+ is_valid, error_msg = TendePayFormatter.validate_payment_details(
998
+ payment_method=payment_method,
999
+ payment_details=payment_details,
1000
+ user_name=user.name
1001
+ )
1002
+
1003
+ if not is_valid:
1004
+ # WARNING - add to warnings but still export
1005
+ warnings.append(error_msg)
1006
+
1007
+ # Format payroll row (always export, even with invalid details)
1008
+ try:
1009
+ row = TendePayFormatter.format_payroll_row(
1010
+ payroll=payroll,
1011
+ user=user,
1012
+ payment_method=payment_method,
1013
+ payment_details=payment_details,
1014
+ user_id_number=user.id_number
1015
+ )
1016
+ csv_rows.append(row)
1017
+
1018
+ # Track successful export
1019
+ exported_payroll_ids.add(payroll.id)
1020
+
1021
+ # Warn if user has no ID number
1022
+ if not user.id_number:
1023
+ warnings.append(f"{user.name}: No ID number - exported with empty ID field")
1024
+
1025
+ except Exception as e:
1026
+ # Even formatting errors should not skip - create minimal row
1027
+ logger.error(f"Failed to format payroll row for {user.name}: {str(e)}")
1028
+ warnings.append(f"{user.name}: Formatting error - {str(e)}")
1029
+ csv_rows.append({
1030
+ "NAME": user.name,
1031
+ "ID NUMBER": user.id_number or "",
1032
+ "PHONE NUMBER": "",
1033
+ "AMOUNT": float(payroll.total_amount),
1034
+ "PAYMENT MODE": "",
1035
+ "BANK (Optional)": "",
1036
+ "BANK ACCOUNT NO (Optional)": "",
1037
+ "PAYBILL BUSINESS NO (Optional)": "",
1038
+ "PAYBILL ACCOUNT NO (Optional)": "",
1039
+ "BUY GOODS TILL NO (Optional)": "",
1040
+ "BILL PAYMENT ACCOUNT NO (Optional)": "",
1041
+ "NARRATION (OPTIONAL)": f"Payroll {payroll.period_start_date} to {payroll.period_end_date}: {float(payroll.total_amount)} KES"
1042
+ })
1043
+ exported_payroll_ids.add(payroll.id)
1044
+
1045
+ # Mark all successfully exported payroll as paid
1046
+ exported_payroll_records = []
1047
+ if exported_payroll_ids:
1048
+ try:
1049
+ db.query(UserPayroll).filter(
1050
+ UserPayroll.id.in_(exported_payroll_ids)
1051
+ ).update(
1052
+ {
1053
+ "is_paid": True,
1054
+ "paid_at": datetime.utcnow(),
1055
+ "payment_method": "tende_pay_export",
1056
+ "payment_reference": payment_reference,
1057
+ "paid_by_user_id": current_user.id
1058
+ },
1059
+ synchronize_session=False
1060
+ )
1061
+ db.commit()
1062
+
1063
+ # Fetch the exported payroll records for notifications
1064
+ exported_payroll_records = db.query(UserPayroll).filter(
1065
+ UserPayroll.id.in_(exported_payroll_ids)
1066
+ ).all()
1067
+
1068
+ logger.info(f"Marked {len(exported_payroll_ids)} payroll records as paid")
1069
+ except Exception as e:
1070
+ db.rollback()
1071
+ logger.error(f"Failed to mark payroll as paid: {str(e)}")
1072
+ raise HTTPException(
1073
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1074
+ detail=f"Failed to mark payroll as paid: {str(e)}"
1075
+ )
1076
+
1077
+ return csv_rows, warnings, exported_payroll_records
src/app/services/project_service.py CHANGED
@@ -7,7 +7,7 @@ from typing import Optional, Dict, Any, List, Tuple
7
  from datetime import datetime, date
8
  from sqlalchemy.orm import Session, joinedload
9
  from sqlalchemy import or_, and_, func, String
10
- from fastapi import HTTPException, status
11
  from uuid import UUID
12
 
13
  from app.models.project import Project, ProjectRegion, ProjectRole, ProjectSubcontractor
@@ -25,6 +25,8 @@ from app.schemas.project import (
25
  )
26
  from app.schemas.filters import ProjectFilters
27
  from app.services.base_filter_service import BaseFilterService
 
 
28
 
29
  logger = logging.getLogger(__name__)
30
 
@@ -645,7 +647,8 @@ class ProjectService(BaseFilterService):
645
  db: Session,
646
  project_id: UUID,
647
  setup_data: 'ProjectSetup',
648
- current_user: User
 
649
  ) -> Dict[str, Any]:
650
  """
651
  Complete project setup: Add regions, roles, subcontractors, and team members.
@@ -778,21 +781,34 @@ class ProjectService(BaseFilterService):
778
  db.add(team_member)
779
  team_members_added += 1
780
 
781
- # Notify user about being added to project
782
  try:
783
- from app.services.notification_helper import NotificationHelper
784
- import asyncio
785
-
786
- asyncio.create_task(
787
- NotificationHelper.notify_user_invited_to_project(
788
- db=db,
789
- user_id=team_data.user_id,
790
- project_id=project_id,
791
- project_name=project.project_name,
792
- invited_by=current_user,
793
- role_name=team_data.role
794
- )
 
 
 
 
 
795
  )
 
 
 
 
 
 
 
 
796
  except Exception as e:
797
  logger.error(f"Failed to send project invitation notification: {str(e)}")
798
 
@@ -1234,16 +1250,11 @@ class ProjectService(BaseFilterService):
1234
  role_name=data.role_name,
1235
  description=data.description,
1236
  compensation_type=data.compensation_type,
1237
- # NEW - Simple fields
1238
- daily_rate=data.daily_rate,
1239
- weekly_rate=data.weekly_rate,
1240
- per_ticket_rate=data.per_ticket_rate,
1241
- # OLD - Legacy fields
1242
- flat_rate_amount=data.flat_rate_amount,
1243
  commission_percentage=data.commission_percentage,
1244
- base_amount=data.base_amount,
1245
- bonus_percentage=data.bonus_percentage,
1246
- hourly_rate=data.hourly_rate,
1247
  is_active=True # Default to active for new roles
1248
  )
1249
 
@@ -1259,75 +1270,69 @@ class ProjectService(BaseFilterService):
1259
  """
1260
  Clear compensation fields that don't apply to the selected type.
1261
  This prevents confusion and ensures only relevant fields have values.
1262
-
1263
  World-class systems (Stripe, QuickBooks) clear irrelevant fields on type change.
1264
  """
1265
  # Clear ALL fields first
1266
- role.daily_rate = None
1267
- role.weekly_rate = None
1268
- role.per_ticket_rate = None
1269
- role.flat_rate_amount = None
1270
  role.commission_percentage = None
1271
- role.base_amount = None
1272
- role.bonus_percentage = None
1273
- role.hourly_rate = None
1274
-
1275
  # Note: The relevant field will be set by the caller after this function
1276
  # This ensures clean state - only one payment method is active
1277
 
1278
  @staticmethod
1279
  def _validate_compensation_structure(data):
1280
- """Validate compensation fields based on type"""
 
 
 
 
 
1281
  comp_type = data.compensation_type
1282
-
1283
- # NEW - Simple types
1284
- if comp_type == 'per_day':
1285
- if not data.daily_rate or data.daily_rate <= 0:
1286
- raise HTTPException(
1287
- status_code=status.HTTP_400_BAD_REQUEST,
1288
- detail="daily_rate is required for per_day compensation (e.g., 1000 KES/day)"
1289
- )
1290
- elif comp_type == 'per_week':
1291
- if not data.weekly_rate or data.weekly_rate <= 0:
1292
  raise HTTPException(
1293
  status_code=status.HTTP_400_BAD_REQUEST,
1294
- detail="weekly_rate is required for per_week compensation (e.g., 7000 KES/week)"
1295
  )
1296
- elif comp_type == 'per_ticket':
1297
- if not data.per_ticket_rate or data.per_ticket_rate <= 0:
1298
  raise HTTPException(
1299
  status_code=status.HTTP_400_BAD_REQUEST,
1300
- detail="per_ticket_rate is required for per_ticket compensation (e.g., 500 KES/ticket)"
1301
  )
1302
- # OLD - Legacy types
1303
- elif comp_type == 'flat_rate':
1304
- if not data.flat_rate_amount or data.flat_rate_amount <= 0:
1305
  raise HTTPException(
1306
  status_code=status.HTTP_400_BAD_REQUEST,
1307
- detail="flat_rate_amount is required for flat_rate compensation"
1308
  )
1309
- elif comp_type == 'commission':
 
1310
  if not data.commission_percentage or data.commission_percentage <= 0:
1311
  raise HTTPException(
1312
  status_code=status.HTTP_400_BAD_REQUEST,
1313
- detail="commission_percentage is required for commission compensation"
1314
  )
1315
- elif comp_type == 'hourly':
1316
- if not data.hourly_rate or data.hourly_rate <= 0:
 
1317
  raise HTTPException(
1318
  status_code=status.HTTP_400_BAD_REQUEST,
1319
- detail="hourly_rate is required for hourly compensation"
1320
  )
1321
- elif comp_type == 'hybrid':
1322
- if not data.base_amount or data.base_amount < 0:
1323
  raise HTTPException(
1324
  status_code=status.HTTP_400_BAD_REQUEST,
1325
- detail="base_amount is required for hybrid compensation"
1326
  )
1327
  if not data.commission_percentage or data.commission_percentage <= 0:
1328
  raise HTTPException(
1329
  status_code=status.HTTP_400_BAD_REQUEST,
1330
- detail="commission_percentage is required for hybrid compensation"
1331
  )
1332
 
1333
  @staticmethod
@@ -1398,16 +1403,10 @@ class ProjectService(BaseFilterService):
1398
  from types import SimpleNamespace
1399
  temp_data = SimpleNamespace(
1400
  compensation_type=data.compensation_type,
1401
- # NEW - Simple fields
1402
- daily_rate=data.daily_rate if data.daily_rate is not None else role.daily_rate,
1403
- weekly_rate=data.weekly_rate if data.weekly_rate is not None else role.weekly_rate,
1404
- per_ticket_rate=data.per_ticket_rate if data.per_ticket_rate is not None else role.per_ticket_rate,
1405
- # OLD - Legacy fields
1406
- flat_rate_amount=data.flat_rate_amount if data.flat_rate_amount is not None else role.flat_rate_amount,
1407
- commission_percentage=data.commission_percentage if data.commission_percentage is not None else role.commission_percentage,
1408
- hourly_rate=data.hourly_rate if data.hourly_rate is not None else role.hourly_rate,
1409
- base_amount=data.base_amount if data.base_amount is not None else role.base_amount,
1410
- bonus_percentage=data.bonus_percentage if data.bonus_percentage is not None else role.bonus_percentage
1411
  )
1412
  ProjectService._validate_compensation_structure(temp_data)
1413
 
 
7
  from datetime import datetime, date
8
  from sqlalchemy.orm import Session, joinedload
9
  from sqlalchemy import or_, and_, func, String
10
+ from fastapi import HTTPException, status, BackgroundTasks
11
  from uuid import UUID
12
 
13
  from app.models.project import Project, ProjectRegion, ProjectRole, ProjectSubcontractor
 
25
  )
26
  from app.schemas.filters import ProjectFilters
27
  from app.services.base_filter_service import BaseFilterService
28
+ from app.services.notification_creator import NotificationCreator
29
+ from app.services.notification_delivery import NotificationDelivery
30
 
31
  logger = logging.getLogger(__name__)
32
 
 
647
  db: Session,
648
  project_id: UUID,
649
  setup_data: 'ProjectSetup',
650
+ current_user: User,
651
+ background_tasks: Optional[BackgroundTasks] = None
652
  ) -> Dict[str, Any]:
653
  """
654
  Complete project setup: Add regions, roles, subcontractors, and team members.
 
781
  db.add(team_member)
782
  team_members_added += 1
783
 
784
+ # Notify user about being added to project (Tier 1 - Synchronous)
785
  try:
786
+ notification = NotificationCreator.create(
787
+ db=db,
788
+ user_id=team_data.user_id,
789
+ title=f"🎯 Added to Project",
790
+ message=f"You have been added to project '{project.project_name}' by {current_user.full_name}.\n\nYour role: {team_data.role}",
791
+ source_type="project",
792
+ source_id=project_id,
793
+ notification_type="project_invitation",
794
+ channel="in_app",
795
+ project_id=project_id,
796
+ metadata={
797
+ "project_id": str(project_id),
798
+ "project_name": project.project_name,
799
+ "invited_by": current_user.full_name,
800
+ "role": team_data.role,
801
+ "action_url": f"/projects/{project_id}"
802
+ }
803
  )
804
+ db.commit()
805
+
806
+ # Queue delivery (Tier 2 - Asynchronous)
807
+ if background_tasks:
808
+ NotificationDelivery.queue_delivery(
809
+ background_tasks=background_tasks,
810
+ notification_id=notification.id
811
+ )
812
  except Exception as e:
813
  logger.error(f"Failed to send project invitation notification: {str(e)}")
814
 
 
1250
  role_name=data.role_name,
1251
  description=data.description,
1252
  compensation_type=data.compensation_type,
1253
+ # Compensation fields
1254
+ base_rate=data.base_rate,
1255
+ rate_period=data.rate_period,
1256
+ per_unit_rate=data.per_unit_rate,
 
 
1257
  commission_percentage=data.commission_percentage,
 
 
 
1258
  is_active=True # Default to active for new roles
1259
  )
1260
 
 
1270
  """
1271
  Clear compensation fields that don't apply to the selected type.
1272
  This prevents confusion and ensures only relevant fields have values.
1273
+
1274
  World-class systems (Stripe, QuickBooks) clear irrelevant fields on type change.
1275
  """
1276
  # Clear ALL fields first
1277
+ role.base_rate = None
1278
+ role.rate_period = None
1279
+ role.per_unit_rate = None
 
1280
  role.commission_percentage = None
1281
+
 
 
 
1282
  # Note: The relevant field will be set by the caller after this function
1283
  # This ensures clean state - only one payment method is active
1284
 
1285
  @staticmethod
1286
  def _validate_compensation_structure(data):
1287
+ """
1288
+ Validate compensation fields based on type
1289
+
1290
+ This validation is redundant with Pydantic schema validation,
1291
+ but provides better error messages for API users.
1292
+ """
1293
  comp_type = data.compensation_type
1294
+
1295
+ if comp_type == 'FIXED_RATE':
1296
+ if not data.base_rate or data.base_rate <= 0:
 
 
 
 
 
 
 
1297
  raise HTTPException(
1298
  status_code=status.HTTP_400_BAD_REQUEST,
1299
+ detail="base_rate is required for FIXED_RATE compensation (e.g., 1000 KES/day, 25 USD/hour)"
1300
  )
1301
+ if not data.rate_period:
 
1302
  raise HTTPException(
1303
  status_code=status.HTTP_400_BAD_REQUEST,
1304
+ detail="rate_period is required for FIXED_RATE compensation (HOUR, DAY, WEEK, or MONTH)"
1305
  )
1306
+
1307
+ elif comp_type == 'PER_UNIT':
1308
+ if not data.per_unit_rate or data.per_unit_rate <= 0:
1309
  raise HTTPException(
1310
  status_code=status.HTTP_400_BAD_REQUEST,
1311
+ detail="per_unit_rate is required for PER_UNIT compensation (e.g., 500 KES/ticket)"
1312
  )
1313
+
1314
+ elif comp_type == 'COMMISSION':
1315
  if not data.commission_percentage or data.commission_percentage <= 0:
1316
  raise HTTPException(
1317
  status_code=status.HTTP_400_BAD_REQUEST,
1318
+ detail="commission_percentage is required for COMMISSION compensation (0-100)"
1319
  )
1320
+
1321
+ elif comp_type == 'FIXED_PLUS_COMMISSION':
1322
+ if not data.base_rate or data.base_rate <= 0:
1323
  raise HTTPException(
1324
  status_code=status.HTTP_400_BAD_REQUEST,
1325
+ detail="base_rate is required for FIXED_PLUS_COMMISSION compensation"
1326
  )
1327
+ if not data.rate_period:
 
1328
  raise HTTPException(
1329
  status_code=status.HTTP_400_BAD_REQUEST,
1330
+ detail="rate_period is required for FIXED_PLUS_COMMISSION compensation"
1331
  )
1332
  if not data.commission_percentage or data.commission_percentage <= 0:
1333
  raise HTTPException(
1334
  status_code=status.HTTP_400_BAD_REQUEST,
1335
+ detail="commission_percentage is required for FIXED_PLUS_COMMISSION compensation"
1336
  )
1337
 
1338
  @staticmethod
 
1403
  from types import SimpleNamespace
1404
  temp_data = SimpleNamespace(
1405
  compensation_type=data.compensation_type,
1406
+ base_rate=data.base_rate if data.base_rate is not None else role.base_rate,
1407
+ rate_period=data.rate_period if data.rate_period is not None else role.rate_period,
1408
+ per_unit_rate=data.per_unit_rate if data.per_unit_rate is not None else role.per_unit_rate,
1409
+ commission_percentage=data.commission_percentage if data.commission_percentage is not None else role.commission_percentage
 
 
 
 
 
 
1410
  )
1411
  ProjectService._validate_compensation_structure(temp_data)
1412
 
src/app/services/sales_order_service.py CHANGED
@@ -31,7 +31,7 @@ Authorization:
31
  """
32
  from sqlalchemy.orm import Session, joinedload
33
  from sqlalchemy import and_, or_, func, desc
34
- from fastapi import HTTPException, status, UploadFile
35
  from typing import List, Tuple, Optional, Dict, Any
36
  from uuid import UUID
37
  from datetime import datetime, date
@@ -47,6 +47,8 @@ from app.models.user import User
47
  from app.models.enums import AppRole, SalesOrderStatus
48
  from app.schemas.sales_order import *
49
  from app.utils.phone_utils import normalize_kenyan_phone
 
 
50
 
51
  logger = logging.getLogger(__name__)
52
 
@@ -796,7 +798,8 @@ class SalesOrderService:
796
  def bulk_create_from_csv(
797
  db: Session,
798
  data: SalesOrderCSVImport,
799
- current_user: User
 
800
  ) -> SalesOrderBulkImportResult:
801
  """
802
  Bulk create sales orders from CSV data with customer deduplication.
@@ -1001,22 +1004,35 @@ class SalesOrderService:
1001
  db.commit()
1002
  logger.info(f"Bulk import completed: {result.successful} successful, {result.failed} failed, {result.duplicates} duplicates")
1003
 
1004
- # Send notification to user about import results
1005
  try:
1006
- from app.services.notification_helper import NotificationHelper
1007
- import asyncio
1008
- asyncio.create_task(
1009
- NotificationHelper.notify_bulk_import_complete(
1010
- db=db,
1011
- user_id=current_user.id,
1012
- entity_type='sales_orders',
1013
- total=result.total_rows,
1014
- successful=result.successful,
1015
- failed=result.failed,
1016
- project_id=data.project_id,
1017
- errors=result.errors[:5] # First 5 errors only
1018
- )
 
 
 
 
 
1019
  )
 
 
 
 
 
 
 
 
1020
  except Exception as e:
1021
  logger.error(f"Failed to send bulk import notification: {str(e)}")
1022
  else:
@@ -1025,22 +1041,35 @@ class SalesOrderService:
1025
  if result.duplicates > 0:
1026
  logger.info(f"Bulk import: All {result.duplicates} rows were duplicates")
1027
 
1028
- # Still notify user about duplicates
1029
  try:
1030
- from app.services.notification_helper import NotificationHelper
1031
- import asyncio
1032
- asyncio.create_task(
1033
- NotificationHelper.notify_bulk_import_complete(
1034
- db=db,
1035
- user_id=current_user.id,
1036
- entity_type='sales_orders',
1037
- total=result.total_rows,
1038
- successful=0,
1039
- failed=result.duplicates,
1040
- project_id=data.project_id,
1041
- errors=["All records were duplicates"]
1042
- )
 
 
 
 
 
1043
  )
 
 
 
 
 
 
 
 
1044
  except Exception as e:
1045
  logger.error(f"Failed to send bulk import notification: {str(e)}")
1046
  else:
@@ -1198,7 +1227,8 @@ class SalesOrderService:
1198
  def bulk_promote_to_tickets(
1199
  db: Session,
1200
  data: SalesOrderBulkPromote,
1201
- current_user: User
 
1202
  ) -> SalesOrderBulkPromoteResult:
1203
  """Bulk promote sales orders to tickets"""
1204
  result = SalesOrderBulkPromoteResult(
@@ -1230,31 +1260,43 @@ class SalesOrderService:
1230
 
1231
  logger.info(f"Bulk promote completed: {result.successful} successful, {result.failed} failed")
1232
 
1233
- # Send notification to user about promotion results
1234
  try:
1235
- from app.services.notification_helper import NotificationHelper
1236
  from app.models.ticket import Ticket
1237
- import asyncio
1238
-
1239
  # Get project_id from first created ticket
1240
  project_id = None
1241
  if result.created_ticket_ids:
1242
  first_ticket = db.query(Ticket).filter(Ticket.id == result.created_ticket_ids[0]).first()
1243
  if first_ticket:
1244
  project_id = first_ticket.project_id
1245
-
1246
- asyncio.create_task(
1247
- NotificationHelper.notify_bulk_promote_complete(
1248
- db=db,
1249
- user_id=current_user.id,
1250
- total=result.total_orders,
1251
- successful=result.successful,
1252
- failed=result.failed,
1253
- created_ticket_ids=result.created_ticket_ids,
1254
- project_id=project_id,
1255
- errors=result.errors[:5] # First 5 errors only
1256
- )
 
 
 
 
 
 
1257
  )
 
 
 
 
 
 
 
 
1258
  except Exception as e:
1259
  logger.error(f"Failed to send bulk promote notification: {str(e)}")
1260
 
 
31
  """
32
  from sqlalchemy.orm import Session, joinedload
33
  from sqlalchemy import and_, or_, func, desc
34
+ from fastapi import HTTPException, status, UploadFile, BackgroundTasks
35
  from typing import List, Tuple, Optional, Dict, Any
36
  from uuid import UUID
37
  from datetime import datetime, date
 
47
  from app.models.enums import AppRole, SalesOrderStatus
48
  from app.schemas.sales_order import *
49
  from app.utils.phone_utils import normalize_kenyan_phone
50
+ from app.services.notification_creator import NotificationCreator
51
+ from app.services.notification_delivery import NotificationDelivery
52
 
53
  logger = logging.getLogger(__name__)
54
 
 
798
  def bulk_create_from_csv(
799
  db: Session,
800
  data: SalesOrderCSVImport,
801
+ current_user: User,
802
+ background_tasks: Optional[BackgroundTasks] = None
803
  ) -> SalesOrderBulkImportResult:
804
  """
805
  Bulk create sales orders from CSV data with customer deduplication.
 
1004
  db.commit()
1005
  logger.info(f"Bulk import completed: {result.successful} successful, {result.failed} failed, {result.duplicates} duplicates")
1006
 
1007
+ # Send notification to user about import results (Tier 1 - Synchronous)
1008
  try:
1009
+ notification = NotificationCreator.create(
1010
+ db=db,
1011
+ user_id=current_user.id,
1012
+ title=f"📦 Sales Orders Import Complete",
1013
+ message=f"Imported {result.successful} of {result.total_rows} sales orders successfully.\n\n✅ Successful: {result.successful}\n❌ Failed: {result.failed}\n🔄 Duplicates: {result.duplicates}",
1014
+ source_type="sales_order",
1015
+ source_id=None,
1016
+ notification_type="bulk_import_complete",
1017
+ channel="in_app",
1018
+ project_id=data.project_id,
1019
+ metadata={
1020
+ "entity_type": "sales_orders",
1021
+ "total": result.total_rows,
1022
+ "successful": result.successful,
1023
+ "failed": result.failed,
1024
+ "duplicates": result.duplicates,
1025
+ "errors": result.errors[:5] # First 5 errors only
1026
+ }
1027
  )
1028
+ db.commit()
1029
+
1030
+ # Queue delivery (Tier 2 - Asynchronous)
1031
+ if background_tasks:
1032
+ NotificationDelivery.queue_delivery(
1033
+ background_tasks=background_tasks,
1034
+ notification_id=notification.id
1035
+ )
1036
  except Exception as e:
1037
  logger.error(f"Failed to send bulk import notification: {str(e)}")
1038
  else:
 
1041
  if result.duplicates > 0:
1042
  logger.info(f"Bulk import: All {result.duplicates} rows were duplicates")
1043
 
1044
+ # Still notify user about duplicates (Tier 1 - Synchronous)
1045
  try:
1046
+ notification = NotificationCreator.create(
1047
+ db=db,
1048
+ user_id=current_user.id,
1049
+ title=f"⚠️ Sales Orders Import - All Duplicates",
1050
+ message=f"All {result.duplicates} sales orders in the import were duplicates.\n\nNo new records were created.",
1051
+ source_type="sales_order",
1052
+ source_id=None,
1053
+ notification_type="bulk_import_complete",
1054
+ channel="in_app",
1055
+ project_id=data.project_id,
1056
+ metadata={
1057
+ "entity_type": "sales_orders",
1058
+ "total": result.total_rows,
1059
+ "successful": 0,
1060
+ "failed": 0,
1061
+ "duplicates": result.duplicates,
1062
+ "errors": ["All records were duplicates"]
1063
+ }
1064
  )
1065
+ db.commit()
1066
+
1067
+ # Queue delivery (Tier 2 - Asynchronous)
1068
+ if background_tasks:
1069
+ NotificationDelivery.queue_delivery(
1070
+ background_tasks=background_tasks,
1071
+ notification_id=notification.id
1072
+ )
1073
  except Exception as e:
1074
  logger.error(f"Failed to send bulk import notification: {str(e)}")
1075
  else:
 
1227
  def bulk_promote_to_tickets(
1228
  db: Session,
1229
  data: SalesOrderBulkPromote,
1230
+ current_user: User,
1231
+ background_tasks: Optional[BackgroundTasks] = None
1232
  ) -> SalesOrderBulkPromoteResult:
1233
  """Bulk promote sales orders to tickets"""
1234
  result = SalesOrderBulkPromoteResult(
 
1260
 
1261
  logger.info(f"Bulk promote completed: {result.successful} successful, {result.failed} failed")
1262
 
1263
+ # Send notification to user about promotion results (Tier 1 - Synchronous)
1264
  try:
 
1265
  from app.models.ticket import Ticket
1266
+
 
1267
  # Get project_id from first created ticket
1268
  project_id = None
1269
  if result.created_ticket_ids:
1270
  first_ticket = db.query(Ticket).filter(Ticket.id == result.created_ticket_ids[0]).first()
1271
  if first_ticket:
1272
  project_id = first_ticket.project_id
1273
+
1274
+ notification = NotificationCreator.create(
1275
+ db=db,
1276
+ user_id=current_user.id,
1277
+ title=f"🎫 Bulk Promote Complete",
1278
+ message=f"Promoted {result.successful} of {result.total_orders} sales orders to tickets.\n\n✅ Successful: {result.successful}\n❌ Failed: {result.failed}",
1279
+ source_type="sales_order",
1280
+ source_id=None,
1281
+ notification_type="bulk_promote_complete",
1282
+ channel="in_app",
1283
+ project_id=project_id,
1284
+ metadata={
1285
+ "total": result.total_orders,
1286
+ "successful": result.successful,
1287
+ "failed": result.failed,
1288
+ "created_ticket_ids": [str(tid) for tid in result.created_ticket_ids],
1289
+ "errors": result.errors[:5] # First 5 errors only
1290
+ }
1291
  )
1292
+ db.commit()
1293
+
1294
+ # Queue delivery (Tier 2 - Asynchronous)
1295
+ if background_tasks:
1296
+ NotificationDelivery.queue_delivery(
1297
+ background_tasks=background_tasks,
1298
+ notification_id=notification.id
1299
+ )
1300
  except Exception as e:
1301
  logger.error(f"Failed to send bulk promote notification: {str(e)}")
1302
 
src/app/services/task_service.py CHANGED
@@ -7,7 +7,7 @@ from typing import Optional, Dict, Any, List, Tuple
7
  from datetime import datetime, date
8
  from sqlalchemy.orm import Session, joinedload
9
  from sqlalchemy import or_, and_, func
10
- from fastapi import HTTPException, status
11
  from uuid import UUID
12
 
13
  from app.models.task import Task
@@ -15,6 +15,8 @@ from app.models.project import Project, ProjectRegion
15
  from app.models.user import User
16
  from app.models.enums import AppRole, TaskStatus, TicketPriority
17
  from app.schemas.task import TaskCreate, TaskUpdate, TaskStatusUpdate, TaskStart, TaskComplete, TaskCancel
 
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
@@ -82,7 +84,8 @@ class TaskService:
82
  def create_task(
83
  db: Session,
84
  data: TaskCreate,
85
- current_user: User
 
86
  ) -> Task:
87
  """
88
  Create a new task for an infrastructure project
@@ -149,21 +152,34 @@ class TaskService:
149
 
150
  logger.info(f"Task created: {task.id} - {task.task_title} for project {project.id} by user {current_user.id}")
151
 
152
- # Notify PMs/managers about new task
153
  try:
154
- from app.services.notification_helper import NotificationHelper
155
- import asyncio
156
-
157
- notify_users = NotificationHelper.get_project_managers_and_dispatchers(db, task.project_id)
158
-
159
- if notify_users:
160
- asyncio.create_task(
161
- NotificationHelper.notify_task_created(
162
- db=db,
163
- task=task,
164
- created_by=current_user,
165
- notify_users=notify_users
166
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  )
168
  except Exception as e:
169
  logger.error(f"Failed to send task creation notification: {str(e)}")
@@ -461,7 +477,8 @@ class TaskService:
461
  db: Session,
462
  task_id: UUID,
463
  data: TaskComplete,
464
- current_user: User
 
465
  ) -> Task:
466
  """Complete a task"""
467
  task = TaskService.get_task_by_id(db, task_id, current_user)
@@ -488,21 +505,34 @@ class TaskService:
488
 
489
  logger.info(f"Task completed: {task.id}")
490
 
491
- # Notify PMs/managers about task completion
492
  try:
493
- from app.services.notification_helper import NotificationHelper
494
- import asyncio
495
-
496
- notify_users = NotificationHelper.get_project_managers_and_dispatchers(db, task.project_id)
497
-
498
- if notify_users:
499
- asyncio.create_task(
500
- NotificationHelper.notify_task_completed(
501
- db=db,
502
- task=task,
503
- completed_by=current_user,
504
- notify_users=notify_users
505
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  )
507
  except Exception as e:
508
  logger.error(f"Failed to send task completion notification: {str(e)}")
@@ -514,7 +544,8 @@ class TaskService:
514
  db: Session,
515
  task_id: UUID,
516
  data: TaskCancel,
517
- current_user: User
 
518
  ) -> Task:
519
  """Cancel a task"""
520
  task = TaskService.get_task_by_id(db, task_id, current_user)
@@ -536,22 +567,33 @@ class TaskService:
536
 
537
  logger.info(f"Task cancelled: {task.id}")
538
 
539
- # Notify PMs/managers about task cancellation
540
  try:
541
- from app.services.notification_helper import NotificationHelper
542
- import asyncio
543
-
544
- notify_users = NotificationHelper.get_project_managers_and_dispatchers(db, task.project_id)
545
-
546
- if notify_users:
547
- asyncio.create_task(
548
- NotificationHelper.notify_task_cancelled(
549
- db=db,
550
- task=task,
551
- cancelled_by=current_user,
552
- reason=data.cancellation_reason,
553
- notify_users=notify_users
554
- )
 
 
 
 
 
 
 
 
 
 
 
555
  )
556
  except Exception as e:
557
  logger.error(f"Failed to send task cancellation notification: {str(e)}")
 
7
  from datetime import datetime, date
8
  from sqlalchemy.orm import Session, joinedload
9
  from sqlalchemy import or_, and_, func
10
+ from fastapi import HTTPException, status, BackgroundTasks
11
  from uuid import UUID
12
 
13
  from app.models.task import Task
 
15
  from app.models.user import User
16
  from app.models.enums import AppRole, TaskStatus, TicketPriority
17
  from app.schemas.task import TaskCreate, TaskUpdate, TaskStatusUpdate, TaskStart, TaskComplete, TaskCancel
18
+ from app.services.notification_creator import NotificationCreator
19
+ from app.services.notification_delivery import NotificationDelivery
20
 
21
  logger = logging.getLogger(__name__)
22
 
 
84
  def create_task(
85
  db: Session,
86
  data: TaskCreate,
87
+ current_user: User,
88
+ background_tasks: Optional[BackgroundTasks] = None
89
  ) -> Task:
90
  """
91
  Create a new task for an infrastructure project
 
152
 
153
  logger.info(f"Task created: {task.id} - {task.task_title} for project {project.id} by user {current_user.id}")
154
 
155
+ # Notify PMs/managers about new task (Tier 1 - Synchronous)
156
  try:
157
+ notification_ids = NotificationCreator.notify_project_team(
158
+ db=db,
159
+ project_id=task.project_id,
160
+ title=f"📋 New Task Created",
161
+ message=f"Task '{task.task_title}' was created by {current_user.full_name}.\n\nType: {task.task_type or 'General'}\nPriority: {task.priority.value if task.priority else 'Normal'}",
162
+ source_type="task",
163
+ source_id=task.id,
164
+ notification_type="task_created",
165
+ channel="in_app",
166
+ roles=["project_manager", "dispatcher"],
167
+ metadata={
168
+ "task_id": str(task.id),
169
+ "task_title": task.task_title,
170
+ "task_type": task.task_type,
171
+ "priority": task.priority.value if task.priority else None,
172
+ "created_by": current_user.full_name,
173
+ "action_url": f"/tasks/{task.id}"
174
+ }
175
+ )
176
+ db.commit()
177
+
178
+ # Queue delivery (Tier 2 - Asynchronous)
179
+ if background_tasks and notification_ids:
180
+ NotificationDelivery.queue_bulk_delivery(
181
+ background_tasks=background_tasks,
182
+ notification_ids=notification_ids
183
  )
184
  except Exception as e:
185
  logger.error(f"Failed to send task creation notification: {str(e)}")
 
477
  db: Session,
478
  task_id: UUID,
479
  data: TaskComplete,
480
+ current_user: User,
481
+ background_tasks: Optional[BackgroundTasks] = None
482
  ) -> Task:
483
  """Complete a task"""
484
  task = TaskService.get_task_by_id(db, task_id, current_user)
 
505
 
506
  logger.info(f"Task completed: {task.id}")
507
 
508
+ # Notify PMs/managers about task completion (Tier 1 - Synchronous)
509
  try:
510
+ notification_ids = NotificationCreator.notify_project_team(
511
+ db=db,
512
+ project_id=task.project_id,
513
+ title=f"✅ Task Completed",
514
+ message=f"Task '{task.task_title}' was completed by {current_user.full_name}.",
515
+ source_type="task",
516
+ source_id=task.id,
517
+ notification_type="task_completed",
518
+ channel="in_app",
519
+ roles=["project_manager", "dispatcher"],
520
+ metadata={
521
+ "task_id": str(task.id),
522
+ "task_title": task.task_title,
523
+ "completed_by": current_user.full_name,
524
+ "completed_at": str(task.completed_at),
525
+ "completion_notes": data.completion_notes,
526
+ "action_url": f"/tasks/{task.id}"
527
+ }
528
+ )
529
+ db.commit()
530
+
531
+ # Queue delivery (Tier 2 - Asynchronous)
532
+ if background_tasks and notification_ids:
533
+ NotificationDelivery.queue_bulk_delivery(
534
+ background_tasks=background_tasks,
535
+ notification_ids=notification_ids
536
  )
537
  except Exception as e:
538
  logger.error(f"Failed to send task completion notification: {str(e)}")
 
544
  db: Session,
545
  task_id: UUID,
546
  data: TaskCancel,
547
+ current_user: User,
548
+ background_tasks: Optional[BackgroundTasks] = None
549
  ) -> Task:
550
  """Cancel a task"""
551
  task = TaskService.get_task_by_id(db, task_id, current_user)
 
567
 
568
  logger.info(f"Task cancelled: {task.id}")
569
 
570
+ # Notify PMs/managers about task cancellation (Tier 1 - Synchronous)
571
  try:
572
+ notification_ids = NotificationCreator.notify_project_team(
573
+ db=db,
574
+ project_id=task.project_id,
575
+ title=f"❌ Task Cancelled",
576
+ message=f"Task '{task.task_title}' was cancelled by {current_user.full_name}.\n\nReason: {data.cancellation_reason}",
577
+ source_type="task",
578
+ source_id=task.id,
579
+ notification_type="task_cancelled",
580
+ channel="in_app",
581
+ roles=["project_manager", "dispatcher"],
582
+ metadata={
583
+ "task_id": str(task.id),
584
+ "task_title": task.task_title,
585
+ "cancelled_by": current_user.full_name,
586
+ "cancellation_reason": data.cancellation_reason,
587
+ "action_url": f"/tasks/{task.id}"
588
+ }
589
+ )
590
+ db.commit()
591
+
592
+ # Queue delivery (Tier 2 - Asynchronous)
593
+ if background_tasks and notification_ids:
594
+ NotificationDelivery.queue_bulk_delivery(
595
+ background_tasks=background_tasks,
596
+ notification_ids=notification_ids
597
  )
598
  except Exception as e:
599
  logger.error(f"Failed to send task cancellation notification: {str(e)}")
src/app/services/tende_pay_formatter.py CHANGED
@@ -390,5 +390,114 @@ class TendePayFormatter:
390
 
391
  elif payment_mode == "BUYGOODS":
392
  row["BUY GOODS TILL NO (Optional)"] = payment_details.get("till_number", "")
393
-
394
  return row
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
  elif payment_mode == "BUYGOODS":
392
  row["BUY GOODS TILL NO (Optional)"] = payment_details.get("till_number", "")
393
+
394
  return row
395
+
396
+ @staticmethod
397
+ def format_payroll_row(
398
+ payroll,
399
+ user,
400
+ payment_method: str,
401
+ payment_details: Dict,
402
+ user_id_number: Optional[str] = None
403
+ ) -> Dict[str, str]:
404
+ """
405
+ Format a payroll record into a Tende Pay CSV row.
406
+
407
+ Args:
408
+ payroll: UserPayroll object
409
+ user: User object
410
+ payment_method: Payment method (send_money, bank_transfer, etc.)
411
+ payment_details: Payment details dict from user_financial_account
412
+ user_id_number: User's ID number (optional)
413
+
414
+ Returns:
415
+ Dict with Tende Pay CSV columns
416
+ """
417
+ # Build payroll narration
418
+ narration = TendePayFormatter.build_payroll_narration(payroll)
419
+
420
+ # Map payment mode
421
+ payment_mode = TendePayFormatter.PAYMENT_MODE_MAP.get(payment_method)
422
+ if not payment_mode:
423
+ raise ValueError(f"Unsupported payment method: {payment_method}")
424
+
425
+ # Base row structure (all Tende Pay columns)
426
+ row = {
427
+ "NAME": user.name,
428
+ "ID NUMBER": user_id_number or "",
429
+ "PHONE NUMBER": "",
430
+ "AMOUNT": float(payroll.total_amount),
431
+ "PAYMENT MODE": payment_mode,
432
+ "BANK (Optional)": "",
433
+ "BANK ACCOUNT NO (Optional)": "",
434
+ "PAYBILL BUSINESS NO (Optional)": "",
435
+ "PAYBILL ACCOUNT NO (Optional)": "",
436
+ "BUY GOODS TILL NO (Optional)": "",
437
+ "BILL PAYMENT BILLER CODE (Optional)": "",
438
+ "BILL PAYMENT ACCOUNT NO (Optional)": "",
439
+ "NARRATION (OPTIONAL)": narration
440
+ }
441
+
442
+ # Fill in method-specific fields
443
+ if payment_mode == "MPESA":
444
+ phone = payment_details.get("phone_number")
445
+ row["PHONE NUMBER"] = TendePayFormatter.normalize_phone(phone)
446
+
447
+ elif payment_mode == "BANK":
448
+ row["BANK (Optional)"] = payment_details.get("bank_name", "")
449
+ row["BANK ACCOUNT NO (Optional)"] = payment_details.get("account_number", "")
450
+
451
+ elif payment_mode == "PAYBILL":
452
+ row["PAYBILL BUSINESS NO (Optional)"] = payment_details.get("business_number", "")
453
+ row["PAYBILL ACCOUNT NO (Optional)"] = payment_details.get("account_number", "")
454
+
455
+ elif payment_mode == "BUYGOODS":
456
+ row["BUY GOODS TILL NO (Optional)"] = payment_details.get("till_number", "")
457
+
458
+ return row
459
+
460
+ @staticmethod
461
+ def build_payroll_narration(
462
+ payroll,
463
+ max_length: int = MAX_NARRATION_LENGTH
464
+ ) -> str:
465
+ """
466
+ Build narration for payroll payment.
467
+
468
+ Format: "Payroll {period}: {days} days, {tickets} tickets, {amount} KES"
469
+ Example: "Payroll 2024-12-02 to 2024-12-08: 5 days worked, 12 tickets closed, 5000 KES"
470
+
471
+ Args:
472
+ payroll: UserPayroll object
473
+ max_length: Maximum narration length
474
+
475
+ Returns:
476
+ Formatted narration string
477
+ """
478
+ # Build period string
479
+ period_str = f"{payroll.period_start_date.isoformat()} to {payroll.period_end_date.isoformat()}"
480
+
481
+ # Build work summary
482
+ work_parts = []
483
+ if payroll.days_worked:
484
+ work_parts.append(f"{payroll.days_worked} days worked")
485
+ if payroll.tickets_closed:
486
+ work_parts.append(f"{payroll.tickets_closed} tickets closed")
487
+
488
+ work_summary = ", ".join(work_parts) if work_parts else "No work recorded"
489
+
490
+ # Build full narration
491
+ narration = (
492
+ f"Payroll {period_str}: "
493
+ f"{work_summary}, "
494
+ f"{float(payroll.total_amount)} KES"
495
+ )
496
+
497
+ # Truncate if too long
498
+ if len(narration) > max_length:
499
+ truncated = narration[:max_length - 3] + "..."
500
+ logger.warning(f"Payroll narration truncated from {len(narration)} to {max_length} chars")
501
+ return truncated
502
+
503
+ return narration
src/app/services/ticket_assignment_service.py CHANGED
@@ -130,13 +130,36 @@ class TicketAssignmentService:
130
 
131
  self.db.commit()
132
  self.db.refresh(assignment)
133
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  # Send notification to agent (non-blocking)
135
  try:
136
  logger.info(f"Ticket {ticket.id} assigned to {agent.full_name} - notification queued")
137
  except Exception as e:
138
  logger.warning(f"Failed to queue assignment notification: {str(e)}")
139
-
140
  return self._to_response(assignment)
141
 
142
  def assign_team(
@@ -418,21 +441,44 @@ class TicketAssignmentService:
418
 
419
  self.db.commit()
420
  self.db.refresh(assignment)
421
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  # Notify PM/Dispatcher about self-assignment
423
  # Note: Notifications are non-blocking and failures don't affect the operation
424
  try:
425
  from app.services.notification_helper import NotificationHelper
426
-
427
  notify_users = NotificationHelper.get_project_managers_and_dispatchers(self.db, ticket.project_id)
428
-
429
  if notify_users:
430
  # Use background task or queue for async notifications
431
  # For now, we'll skip to avoid blocking the request
432
  logger.info(f"Ticket {ticket.id} self-assigned by {agent.full_name} - notification queued")
433
  except Exception as e:
434
  logger.warning(f"Failed to queue self-assignment notification: {str(e)}")
435
-
436
  return self._to_response(assignment)
437
 
438
  # ============================================
@@ -457,10 +503,33 @@ class TicketAssignmentService:
457
  )
458
 
459
  assignment.mark_accepted(data.notes)
460
-
461
  self.db.commit()
462
  self.db.refresh(assignment)
463
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  return self._to_response(assignment)
465
 
466
  def reject_assignment(
@@ -507,13 +576,36 @@ class TicketAssignmentService:
507
 
508
  self.db.commit()
509
  self.db.refresh(assignment)
510
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  # Notify dispatcher/PM about rejection (non-blocking)
512
  try:
513
  logger.info(f"Assignment {assignment.id} rejected - notification queued")
514
  except Exception as e:
515
  logger.warning(f"Failed to queue rejection notification: {str(e)}")
516
-
517
  return self._to_response(assignment)
518
 
519
  def start_journey(
@@ -784,13 +876,33 @@ class TicketAssignmentService:
784
 
785
  self.db.commit()
786
  self.db.refresh(assignment)
787
-
788
- # Notify PM/dispatcher about drop (non-blocking)
789
  try:
790
- logger.info(f"Ticket {ticket.id} dropped by agent - notification queued")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  except Exception as e:
792
- logger.warning(f"Failed to queue drop notification: {str(e)}")
793
-
 
 
 
794
  return self._to_response(assignment)
795
 
796
  def complete_assignment(
@@ -913,13 +1025,36 @@ class TicketAssignmentService:
913
 
914
  self.db.commit()
915
  self.db.refresh(assignment)
916
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
917
  # Notify PM/Dispatcher about ticket completion (non-blocking)
918
  try:
919
  logger.info(f"Ticket {ticket.id} completed - notification queued")
920
  except Exception as e:
921
  logger.warning(f"Failed to queue completion notification: {str(e)}")
922
-
923
  return self._to_response(assignment)
924
 
925
  # ============================================
 
130
 
131
  self.db.commit()
132
  self.db.refresh(assignment)
133
+
134
+ # Real-time timesheet update (best-effort, don't block on failure)
135
+ try:
136
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
137
+ from datetime import date
138
+
139
+ timesheet_id = update_timesheet_for_event(
140
+ db=self.db,
141
+ user_id=user_id,
142
+ project_id=ticket.project_id,
143
+ work_date=date.today(),
144
+ event_type='assignment_created',
145
+ entity_type='ticket_assignment',
146
+ entity_id=assignment.id
147
+ )
148
+
149
+ if timesheet_id:
150
+ logger.debug(f"Timesheet {timesheet_id} updated for assignment {assignment.id}")
151
+ else:
152
+ logger.warning(f"Failed to update timesheet for assignment {assignment.id}")
153
+
154
+ except Exception as e:
155
+ logger.error(f"Timesheet update error for assignment {assignment.id}: {e}", exc_info=True)
156
+
157
  # Send notification to agent (non-blocking)
158
  try:
159
  logger.info(f"Ticket {ticket.id} assigned to {agent.full_name} - notification queued")
160
  except Exception as e:
161
  logger.warning(f"Failed to queue assignment notification: {str(e)}")
162
+
163
  return self._to_response(assignment)
164
 
165
  def assign_team(
 
441
 
442
  self.db.commit()
443
  self.db.refresh(assignment)
444
+
445
+ # Real-time timesheet update (best-effort, don't block on failure)
446
+ try:
447
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
448
+ from datetime import date
449
+
450
+ timesheet_id = update_timesheet_for_event(
451
+ db=self.db,
452
+ user_id=user_id,
453
+ project_id=ticket.project_id,
454
+ work_date=date.today(),
455
+ event_type='self_assignment_created',
456
+ entity_type='ticket_assignment',
457
+ entity_id=assignment.id
458
+ )
459
+
460
+ if timesheet_id:
461
+ logger.debug(f"Timesheet {timesheet_id} updated for self-assignment {assignment.id}")
462
+ else:
463
+ logger.warning(f"Failed to update timesheet for self-assignment {assignment.id}")
464
+
465
+ except Exception as e:
466
+ logger.error(f"Timesheet update error for self-assignment {assignment.id}: {e}", exc_info=True)
467
+
468
  # Notify PM/Dispatcher about self-assignment
469
  # Note: Notifications are non-blocking and failures don't affect the operation
470
  try:
471
  from app.services.notification_helper import NotificationHelper
472
+
473
  notify_users = NotificationHelper.get_project_managers_and_dispatchers(self.db, ticket.project_id)
474
+
475
  if notify_users:
476
  # Use background task or queue for async notifications
477
  # For now, we'll skip to avoid blocking the request
478
  logger.info(f"Ticket {ticket.id} self-assigned by {agent.full_name} - notification queued")
479
  except Exception as e:
480
  logger.warning(f"Failed to queue self-assignment notification: {str(e)}")
481
+
482
  return self._to_response(assignment)
483
 
484
  # ============================================
 
503
  )
504
 
505
  assignment.mark_accepted(data.notes)
506
+
507
  self.db.commit()
508
  self.db.refresh(assignment)
509
+
510
+ # Real-time timesheet update (best-effort, don't block on failure)
511
+ try:
512
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
513
+ from datetime import date
514
+
515
+ timesheet_id = update_timesheet_for_event(
516
+ db=self.db,
517
+ user_id=user_id,
518
+ project_id=assignment.ticket.project_id,
519
+ work_date=date.today(),
520
+ event_type='assignment_accepted',
521
+ entity_type='ticket_assignment',
522
+ entity_id=assignment.id
523
+ )
524
+
525
+ if timesheet_id:
526
+ logger.debug(f"Timesheet {timesheet_id} updated for accepted assignment {assignment.id}")
527
+ else:
528
+ logger.warning(f"Failed to update timesheet for accepted assignment {assignment.id}")
529
+
530
+ except Exception as e:
531
+ logger.error(f"Timesheet update error for accepted assignment {assignment.id}: {e}", exc_info=True)
532
+
533
  return self._to_response(assignment)
534
 
535
  def reject_assignment(
 
576
 
577
  self.db.commit()
578
  self.db.refresh(assignment)
579
+
580
+ # Real-time timesheet update (best-effort, don't block on failure)
581
+ try:
582
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
583
+ from datetime import date
584
+
585
+ timesheet_id = update_timesheet_for_event(
586
+ db=self.db,
587
+ user_id=user_id,
588
+ project_id=ticket.project_id,
589
+ work_date=date.today(),
590
+ event_type='assignment_rejected',
591
+ entity_type='ticket_assignment',
592
+ entity_id=assignment.id
593
+ )
594
+
595
+ if timesheet_id:
596
+ logger.debug(f"Timesheet {timesheet_id} updated for rejected assignment {assignment.id}")
597
+ else:
598
+ logger.warning(f"Failed to update timesheet for rejected assignment {assignment.id}")
599
+
600
+ except Exception as e:
601
+ logger.error(f"Timesheet update error for rejected assignment {assignment.id}: {e}", exc_info=True)
602
+
603
  # Notify dispatcher/PM about rejection (non-blocking)
604
  try:
605
  logger.info(f"Assignment {assignment.id} rejected - notification queued")
606
  except Exception as e:
607
  logger.warning(f"Failed to queue rejection notification: {str(e)}")
608
+
609
  return self._to_response(assignment)
610
 
611
  def start_journey(
 
876
 
877
  self.db.commit()
878
  self.db.refresh(assignment)
879
+
880
+ # Real-time timesheet update (best-effort, don't block on failure)
881
  try:
882
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
883
+ from datetime import date
884
+
885
+ timesheet_id = update_timesheet_for_event(
886
+ db=self.db,
887
+ user_id=user_id,
888
+ project_id=ticket.project_id,
889
+ work_date=date.today(),
890
+ event_type='assignment_dropped',
891
+ entity_type='ticket_assignment',
892
+ entity_id=assignment.id
893
+ )
894
+
895
+ if timesheet_id:
896
+ logger.debug(f"Timesheet {timesheet_id} updated for dropped assignment {assignment.id}")
897
+ else:
898
+ logger.warning(f"Failed to update timesheet for dropped assignment {assignment.id}")
899
+
900
  except Exception as e:
901
+ logger.error(f"Timesheet update error for dropped assignment {assignment.id}: {e}", exc_info=True)
902
+
903
+ # Notification is handled in the API endpoint (async context)
904
+ logger.info(f"Ticket {ticket.id} dropped by agent {user_id} - status set to PENDING_REVIEW")
905
+
906
  return self._to_response(assignment)
907
 
908
  def complete_assignment(
 
1025
 
1026
  self.db.commit()
1027
  self.db.refresh(assignment)
1028
+
1029
+ # Real-time timesheet update (best-effort, don't block on failure)
1030
+ try:
1031
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
1032
+ from datetime import date
1033
+
1034
+ timesheet_id = update_timesheet_for_event(
1035
+ db=self.db,
1036
+ user_id=user_id,
1037
+ project_id=ticket.project_id,
1038
+ work_date=date.today(),
1039
+ event_type='assignment_completed',
1040
+ entity_type='ticket_assignment',
1041
+ entity_id=assignment.id
1042
+ )
1043
+
1044
+ if timesheet_id:
1045
+ logger.debug(f"Timesheet {timesheet_id} updated for completed assignment {assignment.id}")
1046
+ else:
1047
+ logger.warning(f"Failed to update timesheet for completed assignment {assignment.id}")
1048
+
1049
+ except Exception as e:
1050
+ logger.error(f"Timesheet update error for completed assignment {assignment.id}: {e}", exc_info=True)
1051
+
1052
  # Notify PM/Dispatcher about ticket completion (non-blocking)
1053
  try:
1054
  logger.info(f"Ticket {ticket.id} completed - notification queued")
1055
  except Exception as e:
1056
  logger.warning(f"Failed to queue completion notification: {str(e)}")
1057
+
1058
  return self._to_response(assignment)
1059
 
1060
  # ============================================
src/app/services/ticket_completion_service.py CHANGED
@@ -519,9 +519,38 @@ class TicketCompletionService:
519
 
520
  db.commit()
521
  db.refresh(ticket)
522
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  logger.info(f"Ticket {ticket.id} completed successfully")
524
-
525
  return {
526
  "success": True,
527
  "message": "Ticket completed successfully!",
 
519
 
520
  db.commit()
521
  db.refresh(ticket)
522
+
523
+ # Real-time timesheet update for all agents who worked on this ticket (best-effort, don't block on failure)
524
+ try:
525
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
526
+ from datetime import date
527
+
528
+ # Update timesheets for all agents who had assignments on this ticket
529
+ for assignment in active_assignments:
530
+ try:
531
+ timesheet_id = update_timesheet_for_event(
532
+ db=db,
533
+ user_id=assignment.user_id,
534
+ project_id=ticket.project_id,
535
+ work_date=date.today(),
536
+ event_type='ticket_completed',
537
+ entity_type='ticket',
538
+ entity_id=ticket.id
539
+ )
540
+
541
+ if timesheet_id:
542
+ logger.debug(f"Timesheet {timesheet_id} updated for agent {assignment.user_id} on completed ticket {ticket.id}")
543
+ else:
544
+ logger.warning(f"Failed to update timesheet for agent {assignment.user_id} on completed ticket {ticket.id}")
545
+
546
+ except Exception as agent_error:
547
+ logger.error(f"Timesheet update error for agent {assignment.user_id} on ticket {ticket.id}: {agent_error}", exc_info=True)
548
+
549
+ except Exception as e:
550
+ logger.error(f"Timesheet update error for completed ticket {ticket.id}: {e}", exc_info=True)
551
+
552
  logger.info(f"Ticket {ticket.id} completed successfully")
553
+
554
  return {
555
  "success": True,
556
  "message": "Ticket completed successfully!",
src/app/services/ticket_expense_service.py CHANGED
@@ -17,7 +17,7 @@ Business Rules:
17
 
18
  from sqlalchemy.orm import Session, joinedload
19
  from sqlalchemy import and_, or_, func, desc
20
- from fastapi import HTTPException, status
21
  from typing import List, Optional, Tuple
22
  from uuid import UUID
23
  from datetime import datetime, date
@@ -39,6 +39,8 @@ from app.schemas.ticket_expense import (
39
  TicketExpenseResponse,
40
  TicketExpenseStats,
41
  )
 
 
42
 
43
  logger = logging.getLogger(__name__)
44
 
@@ -189,13 +191,35 @@ class TicketExpenseService:
189
  db.add(expense)
190
  db.commit()
191
  db.refresh(expense)
192
-
193
  logger.info(
194
  f"Expense created: {expense.id} by user {current_user.id} "
195
  f"for ticket {ticket.id}, amount: {expense.total_cost}, "
196
  f"location_verified: {location_verified}"
197
  )
198
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  return expense
200
 
201
  # ============================================
@@ -300,12 +324,34 @@ class TicketExpenseService:
300
  expense.verification_notes = verification_notes
301
 
302
  expense.updated_at = datetime.utcnow()
303
-
304
  db.commit()
305
  db.refresh(expense)
306
-
307
  logger.info(f"Expense updated: {expense.id} by user {current_user.id}")
308
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  return expense
310
 
311
  # ============================================
@@ -388,36 +434,87 @@ class TicketExpenseService:
388
 
389
  db.commit()
390
  db.refresh(expense)
391
-
392
- # Send notification to agent
393
- if background_tasks:
394
- try:
395
- from app.services.notification_helper import NotificationHelper
396
-
397
- # Get the agent who submitted the expense
398
- agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
399
-
400
- if agent:
401
- if data.is_approved:
402
- background_tasks.add_task(
403
- NotificationHelper.notify_expense_approved,
404
- db=db,
405
- expense=expense,
406
- approved_by=current_user,
407
- agent=agent
408
- )
409
- else:
410
- background_tasks.add_task(
411
- NotificationHelper.notify_expense_rejected,
412
- db=db,
413
- expense=expense,
414
- rejected_by=current_user,
415
- agent=agent,
416
- reason=data.rejection_reason or "No reason provided"
417
- )
418
- except Exception as e:
419
- action = "approved" if data.is_approved else "rejected"
420
- logger.error(f"Failed to queue expense {action} notification: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
  return expense
423
 
@@ -1545,35 +1642,64 @@ class TicketExpenseService:
1545
  for expense in updated_expenses:
1546
  db.refresh(expense)
1547
 
1548
- # Send notifications to agents
1549
- if background_tasks:
1550
- try:
1551
- from app.services.notification_helper import NotificationHelper
1552
-
1553
- for expense in updated_expenses:
1554
- # Get the agent who submitted the expense
1555
- agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
1556
-
1557
- if agent:
1558
- if is_approved:
1559
- background_tasks.add_task(
1560
- NotificationHelper.notify_expense_approved,
1561
- db=db,
1562
- expense=expense,
1563
- approved_by=current_user,
1564
- agent=agent
1565
- )
1566
- else:
1567
- background_tasks.add_task(
1568
- NotificationHelper.notify_expense_rejected,
1569
- db=db,
1570
- expense=expense,
1571
- rejected_by=current_user,
1572
- agent=agent,
1573
- reason=rejection_reason or "No reason provided"
1574
- )
1575
- except Exception as e:
1576
- logger.error(f"Failed to queue bulk {action} notifications: {str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1577
 
1578
  # Notify the initiator (PM) about bulk operation completion
1579
  if background_tasks and updated_expenses:
 
17
 
18
  from sqlalchemy.orm import Session, joinedload
19
  from sqlalchemy import and_, or_, func, desc
20
+ from fastapi import HTTPException, status, BackgroundTasks
21
  from typing import List, Optional, Tuple
22
  from uuid import UUID
23
  from datetime import datetime, date
 
39
  TicketExpenseResponse,
40
  TicketExpenseStats,
41
  )
42
+ from app.services.notification_creator import NotificationCreator
43
+ from app.services.notification_delivery import NotificationDelivery
44
 
45
  logger = logging.getLogger(__name__)
46
 
 
191
  db.add(expense)
192
  db.commit()
193
  db.refresh(expense)
194
+
195
  logger.info(
196
  f"Expense created: {expense.id} by user {current_user.id} "
197
  f"for ticket {ticket.id}, amount: {expense.total_cost}, "
198
  f"location_verified: {location_verified}"
199
  )
200
+
201
+ # Real-time timesheet update (best-effort, don't block on failure)
202
+ try:
203
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
204
+
205
+ timesheet_id = update_timesheet_for_event(
206
+ db=db,
207
+ user_id=current_user.id,
208
+ project_id=ticket.project_id,
209
+ work_date=data.expense_date,
210
+ event_type='expense_created',
211
+ entity_type='ticket_expense',
212
+ entity_id=expense.id
213
+ )
214
+
215
+ if timesheet_id:
216
+ logger.debug(f"Timesheet {timesheet_id} updated for expense {expense.id}")
217
+ else:
218
+ logger.warning(f"Failed to update timesheet for expense {expense.id}")
219
+
220
+ except Exception as e:
221
+ logger.error(f"Timesheet update error for expense {expense.id}: {e}", exc_info=True)
222
+
223
  return expense
224
 
225
  # ============================================
 
324
  expense.verification_notes = verification_notes
325
 
326
  expense.updated_at = datetime.utcnow()
327
+
328
  db.commit()
329
  db.refresh(expense)
330
+
331
  logger.info(f"Expense updated: {expense.id} by user {current_user.id}")
332
+
333
+ # Real-time timesheet update (best-effort, don't block on failure)
334
+ try:
335
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
336
+
337
+ timesheet_id = update_timesheet_for_event(
338
+ db=db,
339
+ user_id=expense.incurred_by_user_id,
340
+ project_id=expense.ticket.project_id,
341
+ work_date=expense.expense_date,
342
+ event_type='expense_updated',
343
+ entity_type='ticket_expense',
344
+ entity_id=expense.id
345
+ )
346
+
347
+ if timesheet_id:
348
+ logger.debug(f"Timesheet {timesheet_id} updated for expense {expense.id}")
349
+ else:
350
+ logger.warning(f"Failed to update timesheet for expense {expense.id}")
351
+
352
+ except Exception as e:
353
+ logger.error(f"Timesheet update error for expense {expense.id}: {e}", exc_info=True)
354
+
355
  return expense
356
 
357
  # ============================================
 
434
 
435
  db.commit()
436
  db.refresh(expense)
437
+
438
+ # Real-time timesheet update (best-effort, don't block on failure)
439
+ try:
440
+ from app.services.timesheet_realtime_service import update_timesheet_for_event
441
+
442
+ event_type = 'expense_approved' if data.is_approved else 'expense_rejected'
443
+
444
+ timesheet_id = update_timesheet_for_event(
445
+ db=db,
446
+ user_id=expense.incurred_by_user_id,
447
+ project_id=expense.ticket.project_id,
448
+ work_date=expense.expense_date,
449
+ event_type=event_type,
450
+ entity_type='ticket_expense',
451
+ entity_id=expense.id
452
+ )
453
+
454
+ if timesheet_id:
455
+ logger.debug(f"Timesheet {timesheet_id} updated for {event_type} expense {expense.id}")
456
+ else:
457
+ logger.warning(f"Failed to update timesheet for {event_type} expense {expense.id}")
458
+
459
+ except Exception as e:
460
+ logger.error(f"Timesheet update error for expense {expense.id}: {e}", exc_info=True)
461
+
462
+ # Send notification to agent (Tier 1 - Synchronous)
463
+ try:
464
+ # Get the agent who submitted the expense
465
+ agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
466
+
467
+ if agent:
468
+ if data.is_approved:
469
+ notification = NotificationCreator.create(
470
+ db=db,
471
+ user_id=agent.id,
472
+ title=f"✅ Expense Approved",
473
+ message=f"Your expense of {expense.total_cost} {expense.currency} has been approved by {current_user.full_name}.",
474
+ source_type="expense",
475
+ source_id=expense.id,
476
+ notification_type="expense_approved",
477
+ channel="in_app",
478
+ project_id=expense.ticket.project_id if expense.ticket else None,
479
+ metadata={
480
+ "expense_id": str(expense.id),
481
+ "amount": str(expense.total_cost),
482
+ "currency": expense.currency,
483
+ "approved_by": current_user.full_name,
484
+ "action_url": f"/expenses/{expense.id}"
485
+ }
486
+ )
487
+ else:
488
+ notification = NotificationCreator.create(
489
+ db=db,
490
+ user_id=agent.id,
491
+ title=f"❌ Expense Rejected",
492
+ message=f"Your expense of {expense.total_cost} {expense.currency} was rejected by {current_user.full_name}.\n\nReason: {data.rejection_reason or 'No reason provided'}",
493
+ source_type="expense",
494
+ source_id=expense.id,
495
+ notification_type="expense_rejected",
496
+ channel="in_app",
497
+ project_id=expense.ticket.project_id if expense.ticket else None,
498
+ metadata={
499
+ "expense_id": str(expense.id),
500
+ "amount": str(expense.total_cost),
501
+ "currency": expense.currency,
502
+ "rejected_by": current_user.full_name,
503
+ "rejection_reason": data.rejection_reason,
504
+ "action_url": f"/expenses/{expense.id}"
505
+ }
506
+ )
507
+ db.commit()
508
+
509
+ # Queue delivery (Tier 2 - Asynchronous)
510
+ if background_tasks:
511
+ NotificationDelivery.queue_delivery(
512
+ background_tasks=background_tasks,
513
+ notification_id=notification.id
514
+ )
515
+ except Exception as e:
516
+ action = "approved" if data.is_approved else "rejected"
517
+ logger.error(f"Failed to send expense {action} notification: {str(e)}")
518
 
519
  return expense
520
 
 
1642
  for expense in updated_expenses:
1643
  db.refresh(expense)
1644
 
1645
+ # Send notifications to agents (Tier 1 - Synchronous)
1646
+ try:
1647
+ notification_ids = []
1648
+ for expense in updated_expenses:
1649
+ # Get the agent who submitted the expense
1650
+ agent = db.query(User).filter(User.id == expense.incurred_by_user_id).first()
1651
+
1652
+ if agent:
1653
+ if is_approved:
1654
+ notification = NotificationCreator.create(
1655
+ db=db,
1656
+ user_id=agent.id,
1657
+ title=f"✅ Expense Approved",
1658
+ message=f"Your expense of {expense.total_cost} {expense.currency} has been approved by {current_user.full_name}.",
1659
+ source_type="expense",
1660
+ source_id=expense.id,
1661
+ notification_type="expense_approved",
1662
+ channel="in_app",
1663
+ project_id=expense.ticket.project_id if expense.ticket else None,
1664
+ metadata={
1665
+ "expense_id": str(expense.id),
1666
+ "amount": str(expense.total_cost),
1667
+ "currency": expense.currency,
1668
+ "approved_by": current_user.full_name,
1669
+ "action_url": f"/expenses/{expense.id}"
1670
+ }
1671
+ )
1672
+ else:
1673
+ notification = NotificationCreator.create(
1674
+ db=db,
1675
+ user_id=agent.id,
1676
+ title=f"❌ Expense Rejected",
1677
+ message=f"Your expense of {expense.total_cost} {expense.currency} was rejected by {current_user.full_name}.\n\nReason: {rejection_reason or 'No reason provided'}",
1678
+ source_type="expense",
1679
+ source_id=expense.id,
1680
+ notification_type="expense_rejected",
1681
+ channel="in_app",
1682
+ project_id=expense.ticket.project_id if expense.ticket else None,
1683
+ metadata={
1684
+ "expense_id": str(expense.id),
1685
+ "amount": str(expense.total_cost),
1686
+ "currency": expense.currency,
1687
+ "rejected_by": current_user.full_name,
1688
+ "rejection_reason": rejection_reason,
1689
+ "action_url": f"/expenses/{expense.id}"
1690
+ }
1691
+ )
1692
+ notification_ids.append(notification.id)
1693
+ db.commit()
1694
+
1695
+ # Queue delivery (Tier 2 - Asynchronous)
1696
+ if background_tasks and notification_ids:
1697
+ NotificationDelivery.queue_bulk_delivery(
1698
+ background_tasks=background_tasks,
1699
+ notification_ids=notification_ids
1700
+ )
1701
+ except Exception as e:
1702
+ logger.error(f"Failed to send bulk {action} notifications: {str(e)}")
1703
 
1704
  # Notify the initiator (PM) about bulk operation completion
1705
  if background_tasks and updated_expenses:
src/app/services/ticket_service.py CHANGED
@@ -21,7 +21,7 @@ Authorization:
21
  """
22
  from sqlalchemy.orm import Session, joinedload
23
  from sqlalchemy import and_, or_, func, desc, case, select
24
- from fastapi import HTTPException, status
25
  from typing import List, Tuple, Optional
26
  from uuid import UUID
27
  from datetime import datetime, date, timedelta
@@ -37,6 +37,8 @@ from app.models.enums import AppRole, TicketSource, TicketType, TicketStatus, Ti
37
  from app.schemas.ticket import *
38
  from app.schemas.filters import TicketFilters
39
  from app.services.base_filter_service import BaseFilterService
 
 
40
 
41
  logger = logging.getLogger(__name__)
42
 
@@ -1006,7 +1008,8 @@ class TicketService(BaseFilterService):
1006
  db: Session,
1007
  ticket_id: UUID,
1008
  data: TicketCancel,
1009
- current_user: User
 
1010
  ) -> Ticket:
1011
  """Cancel ticket"""
1012
  ticket = TicketService.get_ticket_by_id(db, ticket_id, current_user)
@@ -1043,23 +1046,34 @@ class TicketService(BaseFilterService):
1043
 
1044
  logger.info(f"Cancelled ticket {ticket_id}: {data.reason}")
1045
 
1046
- # Notify PM/Dispatcher about cancellation
1047
  try:
1048
- from app.services.notification_helper import NotificationHelper
1049
- import asyncio
1050
-
1051
- notify_users = NotificationHelper.get_project_managers_and_dispatchers(db, ticket.project_id)
1052
-
1053
- if notify_users:
1054
- asyncio.create_task(
1055
- NotificationHelper.notify_ticket_status_changed(
1056
- db=db,
1057
- ticket=ticket,
1058
- old_status=old_status,
1059
- new_status=ticket.status,
1060
- changed_by=current_user,
1061
- notify_users=notify_users
1062
- )
 
 
 
 
 
 
 
 
 
 
 
1063
  )
1064
  except Exception as e:
1065
  logger.error(f"Failed to send ticket cancellation notification: {str(e)}")
@@ -1071,7 +1085,8 @@ class TicketService(BaseFilterService):
1071
  db: Session,
1072
  ticket_id: UUID,
1073
  data: TicketReopen,
1074
- current_user: User
 
1075
  ) -> Ticket:
1076
  """
1077
  Reopen a cancelled, completed, or pending_review ticket.
@@ -1146,23 +1161,34 @@ class TicketService(BaseFilterService):
1146
 
1147
  logger.info(f"Reopened ticket {ticket_id} from {old_status} to {ticket.status}")
1148
 
1149
- # Notify PM/Dispatcher about reopening
1150
  try:
1151
- from app.services.notification_helper import NotificationHelper
1152
- import asyncio
1153
-
1154
- notify_users = NotificationHelper.get_project_managers_and_dispatchers(db, ticket.project_id)
1155
-
1156
- if notify_users:
1157
- asyncio.create_task(
1158
- NotificationHelper.notify_ticket_status_changed(
1159
- db=db,
1160
- ticket=ticket,
1161
- old_status=old_status,
1162
- new_status=ticket.status,
1163
- changed_by=current_user,
1164
- notify_users=notify_users
1165
- )
 
 
 
 
 
 
 
 
 
 
 
1166
  )
1167
  except Exception as e:
1168
  logger.error(f"Failed to send ticket reopen notification: {str(e)}")
 
21
  """
22
  from sqlalchemy.orm import Session, joinedload
23
  from sqlalchemy import and_, or_, func, desc, case, select
24
+ from fastapi import HTTPException, status, BackgroundTasks
25
  from typing import List, Tuple, Optional
26
  from uuid import UUID
27
  from datetime import datetime, date, timedelta
 
37
  from app.schemas.ticket import *
38
  from app.schemas.filters import TicketFilters
39
  from app.services.base_filter_service import BaseFilterService
40
+ from app.services.notification_creator import NotificationCreator
41
+ from app.services.notification_delivery import NotificationDelivery
42
 
43
  logger = logging.getLogger(__name__)
44
 
 
1008
  db: Session,
1009
  ticket_id: UUID,
1010
  data: TicketCancel,
1011
+ current_user: User,
1012
+ background_tasks: Optional[BackgroundTasks] = None
1013
  ) -> Ticket:
1014
  """Cancel ticket"""
1015
  ticket = TicketService.get_ticket_by_id(db, ticket_id, current_user)
 
1046
 
1047
  logger.info(f"Cancelled ticket {ticket_id}: {data.reason}")
1048
 
1049
+ # Notify PM/Dispatcher about cancellation (Tier 1 - Synchronous)
1050
  try:
1051
+ notification_ids = NotificationCreator.notify_project_team(
1052
+ db=db,
1053
+ project_id=ticket.project_id,
1054
+ title=f"🚫 Ticket Cancelled",
1055
+ message=f"Ticket #{ticket.id} was cancelled by {current_user.full_name}.\n\nReason: {data.reason}\nPrevious Status: {old_status.value}",
1056
+ source_type="ticket",
1057
+ source_id=ticket.id,
1058
+ notification_type="ticket_cancelled",
1059
+ channel="in_app",
1060
+ roles=["project_manager", "dispatcher"],
1061
+ metadata={
1062
+ "ticket_id": str(ticket.id),
1063
+ "old_status": old_status.value,
1064
+ "new_status": ticket.status.value,
1065
+ "cancelled_by": current_user.full_name,
1066
+ "cancellation_reason": data.reason,
1067
+ "action_url": f"/tickets/{ticket.id}"
1068
+ }
1069
+ )
1070
+ db.commit()
1071
+
1072
+ # Queue delivery (Tier 2 - Asynchronous)
1073
+ if background_tasks and notification_ids:
1074
+ NotificationDelivery.queue_bulk_delivery(
1075
+ background_tasks=background_tasks,
1076
+ notification_ids=notification_ids
1077
  )
1078
  except Exception as e:
1079
  logger.error(f"Failed to send ticket cancellation notification: {str(e)}")
 
1085
  db: Session,
1086
  ticket_id: UUID,
1087
  data: TicketReopen,
1088
+ current_user: User,
1089
+ background_tasks: Optional[BackgroundTasks] = None
1090
  ) -> Ticket:
1091
  """
1092
  Reopen a cancelled, completed, or pending_review ticket.
 
1161
 
1162
  logger.info(f"Reopened ticket {ticket_id} from {old_status} to {ticket.status}")
1163
 
1164
+ # Notify PM/Dispatcher about reopening (Tier 1 - Synchronous)
1165
  try:
1166
+ notification_ids = NotificationCreator.notify_project_team(
1167
+ db=db,
1168
+ project_id=ticket.project_id,
1169
+ title=f"🔄 Ticket Reopened",
1170
+ message=f"Ticket #{ticket.id} was reopened by {current_user.full_name}.\n\nPrevious Status: {old_status.value}\nReason: {data.reason or 'Not specified'}",
1171
+ source_type="ticket",
1172
+ source_id=ticket.id,
1173
+ notification_type="ticket_reopened",
1174
+ channel="in_app",
1175
+ roles=["project_manager", "dispatcher"],
1176
+ metadata={
1177
+ "ticket_id": str(ticket.id),
1178
+ "old_status": old_status.value,
1179
+ "new_status": ticket.status.value,
1180
+ "reopened_by": current_user.full_name,
1181
+ "reopen_reason": data.reason,
1182
+ "action_url": f"/tickets/{ticket.id}"
1183
+ }
1184
+ )
1185
+ db.commit()
1186
+
1187
+ # Queue delivery (Tier 2 - Asynchronous)
1188
+ if background_tasks and notification_ids:
1189
+ NotificationDelivery.queue_bulk_delivery(
1190
+ background_tasks=background_tasks,
1191
+ notification_ids=notification_ids
1192
  )
1193
  except Exception as e:
1194
  logger.error(f"Failed to send ticket reopen notification: {str(e)}")
src/app/services/timesheet_realtime_service.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Timesheet Real-Time Update Service
3
+
4
+ Handles real-time timesheet updates triggered by business events.
5
+ Implements immutability rules, optimistic locking, and graceful degradation.
6
+
7
+ Business Rules:
8
+ - Timesheets are immutable (only update if work_date == today)
9
+ - Multi-project support (one timesheet per user per project per day)
10
+ - Optimistic locking prevents concurrent update conflicts
11
+ - Failures don't block main operations (graceful degradation)
12
+ """
13
+ from datetime import date, datetime
14
+ from typing import Optional
15
+ from uuid import UUID
16
+ from sqlalchemy.orm import Session
17
+ from sqlalchemy import func, case
18
+ import logging
19
+
20
+ from app.models.timesheet import Timesheet
21
+ from app.models.ticket_assignment import TicketAssignment
22
+ from app.models.ticket_expense import TicketExpense
23
+ from app.models.ticket import Ticket
24
+ from app.models.enums import TimesheetStatus
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class TimesheetRealtimeService:
30
+ """
31
+ Service for real-time timesheet updates.
32
+
33
+ Key Features:
34
+ - Immutability enforcement (only update today's timesheets)
35
+ - Optimistic locking with retry
36
+ - Aggregation from source tables
37
+ - Sync tracking
38
+ """
39
+
40
+ def __init__(self, db: Session):
41
+ self.db = db
42
+
43
+ def update_timesheet_for_event(
44
+ self,
45
+ user_id: UUID,
46
+ project_id: UUID,
47
+ work_date: date,
48
+ event_type: str,
49
+ entity_type: str,
50
+ entity_id: UUID
51
+ ) -> Optional[UUID]:
52
+ """
53
+ Update timesheet for a business event.
54
+
55
+ Args:
56
+ user_id: User who performed the work
57
+ project_id: Project the work belongs to
58
+ work_date: Date of the work
59
+ event_type: Type of event (e.g., 'assignment_created', 'expense_approved')
60
+ entity_type: Type of entity ('ticket_assignment', 'ticket_expense', 'ticket')
61
+ entity_id: ID of the entity that triggered the update
62
+
63
+ Returns:
64
+ Timesheet ID if successful, None if failed or skipped
65
+ """
66
+ try:
67
+ # IMMUTABILITY RULE: Only update today's timesheet
68
+ if work_date < date.today():
69
+ logger.warning(
70
+ f"Skipping timesheet update for past date: {work_date}. "
71
+ f"Event: {event_type}, Entity: {entity_type}:{entity_id}"
72
+ )
73
+ return None
74
+
75
+ if work_date > date.today():
76
+ logger.warning(
77
+ f"Skipping timesheet update for future date: {work_date}. "
78
+ f"Event: {event_type}, Entity: {entity_type}:{entity_id}"
79
+ )
80
+ return None
81
+
82
+ # Aggregate metrics from source tables
83
+ ticket_metrics = self._aggregate_ticket_metrics(user_id, project_id, work_date)
84
+ expense_metrics = self._aggregate_expense_metrics(user_id, project_id, work_date)
85
+
86
+ # Merge all metrics
87
+ all_metrics = {**ticket_metrics, **expense_metrics}
88
+
89
+ # Upsert timesheet with optimistic locking
90
+ timesheet_id = self._upsert_timesheet_with_lock(
91
+ user_id=user_id,
92
+ project_id=project_id,
93
+ work_date=work_date,
94
+ metrics=all_metrics
95
+ )
96
+
97
+ if timesheet_id:
98
+ # Mark source entity as synced
99
+ self._mark_entity_synced(entity_type, entity_id)
100
+ logger.info(
101
+ f"Timesheet {timesheet_id} updated for {event_type}. "
102
+ f"Entity: {entity_type}:{entity_id}"
103
+ )
104
+ else:
105
+ logger.warning(
106
+ f"Failed to update timesheet for {event_type}. "
107
+ f"Entity: {entity_type}:{entity_id}"
108
+ )
109
+
110
+ return timesheet_id
111
+
112
+ except Exception as e:
113
+ logger.error(
114
+ f"Error updating timesheet for {event_type}: {e}",
115
+ exc_info=True
116
+ )
117
+ return None
118
+
119
+ def _aggregate_ticket_metrics(
120
+ self,
121
+ user_id: UUID,
122
+ project_id: UUID,
123
+ work_date: date
124
+ ) -> dict:
125
+ """
126
+ Aggregate ticket metrics for a specific day.
127
+
128
+ Uses MIN(journey_started_at) for check-in
129
+ Uses MAX(ended_at) for check-out
130
+
131
+ Returns:
132
+ Dict with ticket counts and check-in/out times
133
+ """
134
+ try:
135
+ # Query assignments for this day
136
+ query = self.db.query(
137
+ # Count by action type
138
+ func.count(
139
+ case((TicketAssignment.action == 'assigned', 1))
140
+ ).label('tickets_assigned'),
141
+ func.count(
142
+ case((TicketAssignment.action == 'completed', 1))
143
+ ).label('tickets_completed'),
144
+ func.count(
145
+ case((TicketAssignment.action == 'rejected', 1))
146
+ ).label('tickets_rejected'),
147
+ func.count(
148
+ case((TicketAssignment.action == 'dropped', 1))
149
+ ).label('tickets_cancelled'),
150
+
151
+
152
+ # Calculate hours worked
153
+ hours_worked = None
154
+ if result.check_in_time and result.check_out_time:
155
+ delta = result.check_out_time - result.check_in_time
156
+ hours_worked = round(delta.total_seconds() / 3600, 2)
157
+
158
+ return {
159
+ 'tickets_assigned': result.tickets_assigned or 0,
160
+ 'tickets_completed': result.tickets_completed or 0,
161
+ 'tickets_rejected': result.tickets_rejected or 0,
162
+ 'tickets_cancelled': result.tickets_cancelled or 0,
163
+ 'tickets_rescheduled': 0, # Not tracked yet
164
+ 'check_in_time': result.check_in_time,
165
+ 'check_out_time': result.check_out_time,
166
+ 'hours_worked': hours_worked,
167
+ 'status': TimesheetStatus.PRESENT # Default to present if any activity
168
+ }
169
+
170
+ except Exception as e:
171
+ logger.error(f"Error aggregating ticket metrics: {e}", exc_info=True)
172
+ return {
173
+ 'tickets_assigned': 0,
174
+ 'tickets_completed': 0,
175
+ 'tickets_rejected': 0,
176
+ 'tickets_cancelled': 0,
177
+ 'tickets_rescheduled': 0,
178
+ 'check_in_time': None,
179
+ 'check_out_time': None,
180
+ 'hours_worked': None,
181
+ 'status': TimesheetStatus.PRESENT
182
+ }
183
+
184
+ def _aggregate_expense_metrics(
185
+ self,
186
+ user_id: UUID,
187
+ project_id: UUID,
188
+ work_date: date
189
+ ) -> dict:
190
+ """
191
+ Aggregate expense metrics for a specific day.
192
+
193
+ Returns:
194
+ Dict with expense totals by approval status
195
+ """
196
+ try:
197
+ query = self.db.query(
198
+ func.count(TicketExpense.id).label('expense_claims_count'),
199
+ func.sum(TicketExpense.total_cost).label('total_expenses'),
200
+ func.sum(
201
+ case((TicketExpense.is_approved == True, TicketExpense.total_cost), else_=0)
202
+ ).label('approved_expenses'),
203
+ func.sum(
204
+ case((TicketExpense.is_approved.is_(None), TicketExpense.total_cost), else_=0)
205
+ ).label('pending_expenses'),
206
+ func.sum(
207
+ case((TicketExpense.is_approved == False, TicketExpense.total_cost), else_=0)
208
+ ).label('rejected_expenses')
209
+ ).join(
210
+ Ticket, TicketExpense.ticket_id == Ticket.id
211
+ ).filter(
212
+ TicketExpense.incurred_by_user_id == user_id,
213
+ Ticket.project_id == project_id,
214
+ TicketExpense.expense_date == work_date,
215
+ TicketExpense.deleted_at.is_(None),
216
+ Ticket.deleted_at.is_(None)
217
+ )
218
+
219
+ result = query.first()
220
+
221
+ return {
222
+ 'expense_claims_count': result.expense_claims_count or 0,
223
+ 'total_expenses': float(result.total_expenses or 0),
224
+ 'approved_expenses': float(result.approved_expenses or 0),
225
+ 'pending_expenses': float(result.pending_expenses or 0),
226
+ 'rejected_expenses': float(result.rejected_expenses or 0)
227
+ }
228
+
229
+ except Exception as e:
230
+ logger.error(f"Error aggregating expense metrics: {e}", exc_info=True)
231
+ return {
232
+ 'expense_claims_count': 0,
233
+ 'total_expenses': 0.0,
234
+ 'approved_expenses': 0.0,
235
+ 'pending_expenses': 0.0,
236
+ 'rejected_expenses': 0.0
237
+ }
238
+
239
+ def _upsert_timesheet_with_lock(
240
+ self,
241
+ user_id: UUID,
242
+ project_id: UUID,
243
+ work_date: date,
244
+ metrics: dict,
245
+ max_retries: int = 3
246
+ ) -> Optional[UUID]:
247
+ """
248
+ Upsert timesheet with optimistic locking.
249
+ Retries on version conflict.
250
+
251
+ Args:
252
+ user_id: User ID
253
+ project_id: Project ID
254
+ work_date: Work date
255
+ metrics: Aggregated metrics to update
256
+ max_retries: Maximum number of retry attempts
257
+
258
+ Returns:
259
+ Timesheet ID if successful, None if failed
260
+ """
261
+ for attempt in range(max_retries):
262
+ try:
263
+ # Get existing timesheet
264
+ existing = self.db.query(Timesheet).filter(
265
+ Timesheet.user_id == user_id,
266
+ Timesheet.project_id == project_id,
267
+ Timesheet.work_date == work_date,
268
+ Timesheet.deleted_at.is_(None)
269
+ ).first()
270
+
271
+ if existing:
272
+ # UPDATE with version check (optimistic locking)
273
+ current_version = existing.version
274
+
275
+ # Build update dict (exclude None values to preserve existing data)
276
+ update_data = {k: v for k, v in metrics.items() if v is not None}
277
+
278
+ result = self.db.query(Timesheet).filter(
279
+ Timesheet.id == existing.id,
280
+ Timesheet.version == current_version # Optimistic lock
281
+ ).update(update_data, synchronize_session=False)
282
+
283
+ if result == 0:
284
+ # Version conflict - another update happened
285
+ logger.warning(
286
+ f"Optimistic lock conflict on timesheet {existing.id}. "
287
+ f"Retry {attempt + 1}/{max_retries}"
288
+ )
289
+ self.db.rollback()
290
+ continue # Retry
291
+
292
+ self.db.commit()
293
+ logger.debug(f"Updated timesheet {existing.id} (version {current_version} -> {current_version + 1})")
294
+ return existing.id
295
+ else:
296
+ # INSERT new timesheet
297
+ new_timesheet = Timesheet(
298
+ user_id=user_id,
299
+ project_id=project_id,
300
+ work_date=work_date,
301
+ version=1,
302
+ **metrics
303
+ )
304
+ self.db.add(new_timesheet)
305
+ self.db.commit()
306
+ self.db.refresh(new_timesheet)
307
+ logger.debug(f"Created new timesheet {new_timesheet.id}")
308
+ return new_timesheet.id
309
+
310
+ except Exception as e:
311
+ logger.error(f"Error upserting timesheet (attempt {attempt + 1}/{max_retries}): {e}")
312
+ self.db.rollback()
313
+ if attempt == max_retries - 1:
314
+ return None
315
+ continue
316
+
317
+ return None
318
+
319
+ def _mark_entity_synced(
320
+ self,
321
+ entity_type: str,
322
+ entity_id: UUID
323
+ ) -> bool:
324
+ """
325
+ Mark source entity as synced to timesheet.
326
+
327
+ Args:
328
+ entity_type: Type of entity ('ticket_assignment', 'ticket_expense', 'ticket')
329
+ entity_id: ID of the entity
330
+
331
+ Returns:
332
+ True if successful, False otherwise
333
+ """
334
+ try:
335
+ if entity_type == 'ticket_assignment':
336
+ self.db.query(TicketAssignment).filter(
337
+ TicketAssignment.id == entity_id
338
+ ).update({
339
+ 'timesheet_synced': True,
340
+ 'timesheet_synced_at': func.now()
341
+ }, synchronize_session=False)
342
+ elif entity_type == 'ticket_expense':
343
+ self.db.query(TicketExpense).filter(
344
+ TicketExpense.id == entity_id
345
+ ).update({
346
+ 'timesheet_synced': True,
347
+ 'timesheet_synced_at': func.now()
348
+ }, synchronize_session=False)
349
+ elif entity_type == 'ticket':
350
+ self.db.query(Ticket).filter(
351
+ Ticket.id == entity_id
352
+ ).update({
353
+ 'timesheet_synced': True,
354
+ 'timesheet_synced_at': func.now()
355
+ }, synchronize_session=False)
356
+ else:
357
+ logger.warning(f"Unknown entity type: {entity_type}")
358
+ return False
359
+
360
+ self.db.commit()
361
+ return True
362
+ except Exception as e:
363
+ logger.error(f"Failed to mark {entity_type}:{entity_id} as synced: {e}")
364
+ self.db.rollback()
365
+ return False
366
+
367
+
368
+ # Convenience function for use in API endpoints
369
+ def update_timesheet_for_event(
370
+ db: Session,
371
+ user_id: UUID,
372
+ project_id: UUID,
373
+ work_date: date,
374
+ event_type: str,
375
+ entity_type: str,
376
+ entity_id: UUID
377
+ ) -> Optional[UUID]:
378
+ """
379
+ Convenience function to update timesheet for an event.
380
+
381
+ This is the main entry point for API endpoints.
382
+
383
+ Args:
384
+ db: Database session
385
+ user_id: User who performed the work
386
+ project_id: Project the work belongs to
387
+ work_date: Date of the work
388
+ event_type: Type of event (e.g., 'assignment_created')
389
+ entity_type: Type of entity ('ticket_assignment', 'ticket_expense', 'ticket')
390
+ entity_id: ID of the entity
391
+
392
+ Returns:
393
+ Timesheet ID if successful, None if failed
394
+ """
395
+ service = TimesheetRealtimeService(db)
396
+ return service.update_timesheet_for_event(
397
+ user_id=user_id,
398
+ project_id=project_id,
399
+ work_date=work_date,
400
+ event_type=event_type,
401
+ entity_type=entity_type,
402
+ entity_id=entity_id
403
+ )
404
+
supabase/migrations/20241212_realtime_timesheet_updates.sql ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- =====================================================
2
+ -- Real-Time Timesheet Updates - Database Migration
3
+ -- Date: 2024-12-12
4
+ -- Purpose: Add support for real-time timesheet updates
5
+ -- =====================================================
6
+
7
+ -- =====================================================
8
+ -- PART 1: Add Version Column to Timesheets (Optimistic Locking)
9
+ -- =====================================================
10
+
11
+ -- Add version column for optimistic locking
12
+ ALTER TABLE timesheets
13
+ ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1;
14
+
15
+ -- Create trigger function to auto-increment version on update
16
+ CREATE OR REPLACE FUNCTION increment_timesheet_version()
17
+ RETURNS TRIGGER AS $$
18
+ BEGIN
19
+ -- Only increment if this is an UPDATE (not INSERT)
20
+ IF TG_OP = 'UPDATE' THEN
21
+ NEW.version = OLD.version + 1;
22
+ END IF;
23
+ RETURN NEW;
24
+ END;
25
+ $$ LANGUAGE plpgsql;
26
+
27
+ -- Drop trigger if exists (for idempotency)
28
+ DROP TRIGGER IF EXISTS trigger_increment_timesheet_version ON timesheets;
29
+
30
+ -- Create trigger to auto-increment version on update
31
+ CREATE TRIGGER trigger_increment_timesheet_version
32
+ BEFORE UPDATE ON timesheets
33
+ FOR EACH ROW
34
+ EXECUTE FUNCTION increment_timesheet_version();
35
+
36
+ -- Add index for version queries
37
+ CREATE INDEX IF NOT EXISTS idx_timesheets_version ON timesheets(version);
38
+
39
+ COMMENT ON COLUMN timesheets.version IS 'Optimistic locking version - auto-incremented on every update';
40
+
41
+ -- =====================================================
42
+ -- PART 2: Fix Unique Constraint (Multi-Project Support)
43
+ -- =====================================================
44
+
45
+ -- Drop old constraint (user_id, work_date)
46
+ ALTER TABLE timesheets
47
+ DROP CONSTRAINT IF EXISTS uq_timesheets_user_date;
48
+
49
+ -- Add new constraint (user_id, project_id, work_date)
50
+ -- This allows one timesheet per user per project per day
51
+ ALTER TABLE timesheets
52
+ DROP CONSTRAINT IF EXISTS uq_timesheets_user_project_date;
53
+
54
+ ALTER TABLE timesheets
55
+ ADD CONSTRAINT uq_timesheets_user_project_date
56
+ UNIQUE (user_id, project_id, work_date);
57
+
58
+ COMMENT ON CONSTRAINT uq_timesheets_user_project_date ON timesheets IS 'Allows multiple timesheets per day (one per project) - different projects pay different rates';
59
+
60
+ -- =====================================================
61
+ -- PART 3: Add Timesheet Sync Tracking to Source Tables
62
+ -- =====================================================
63
+
64
+ -- Add to ticket_assignments
65
+ ALTER TABLE ticket_assignments
66
+ ADD COLUMN IF NOT EXISTS timesheet_synced BOOLEAN NOT NULL DEFAULT FALSE,
67
+ ADD COLUMN IF NOT EXISTS timesheet_synced_at TIMESTAMP WITH TIME ZONE;
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_ticket_assignments_timesheet_synced
70
+ ON ticket_assignments(timesheet_synced)
71
+ WHERE timesheet_synced = FALSE;
72
+
73
+ COMMENT ON COLUMN ticket_assignments.timesheet_synced IS 'Tracks if this assignment has been synced to timesheets';
74
+ COMMENT ON COLUMN ticket_assignments.timesheet_synced_at IS 'When this assignment was synced to timesheets';
75
+
76
+ -- Add to ticket_expenses
77
+ ALTER TABLE ticket_expenses
78
+ ADD COLUMN IF NOT EXISTS timesheet_synced BOOLEAN NOT NULL DEFAULT FALSE,
79
+ ADD COLUMN IF NOT EXISTS timesheet_synced_at TIMESTAMP WITH TIME ZONE;
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_ticket_expenses_timesheet_synced
82
+ ON ticket_expenses(timesheet_synced)
83
+ WHERE timesheet_synced = FALSE;
84
+
85
+ COMMENT ON COLUMN ticket_expenses.timesheet_synced IS 'Tracks if this expense has been synced to timesheets';
86
+ COMMENT ON COLUMN ticket_expenses.timesheet_synced_at IS 'When this expense was synced to timesheets';
87
+
88
+ -- Add to tickets
89
+ ALTER TABLE tickets
90
+ ADD COLUMN IF NOT EXISTS timesheet_synced BOOLEAN NOT NULL DEFAULT FALSE,
91
+ ADD COLUMN IF NOT EXISTS timesheet_synced_at TIMESTAMP WITH TIME ZONE;
92
+
93
+ CREATE INDEX IF NOT EXISTS idx_tickets_timesheet_synced
94
+ ON tickets(timesheet_synced)
95
+ WHERE timesheet_synced = FALSE;
96
+
97
+ COMMENT ON COLUMN tickets.timesheet_synced IS 'Tracks if this ticket completion has been synced to timesheets';
98
+ COMMENT ON COLUMN tickets.timesheet_synced_at IS 'When this ticket was synced to timesheets';
99
+
100
+ -- =====================================================
101
+ -- PART 4: Add Missing Expense Columns to Timesheets
102
+ -- =====================================================
103
+
104
+ -- Check if expense columns exist, add if missing
105
+ ALTER TABLE timesheets
106
+ ADD COLUMN IF NOT EXISTS total_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
107
+ ADD COLUMN IF NOT EXISTS approved_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
108
+ ADD COLUMN IF NOT EXISTS pending_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
109
+ ADD COLUMN IF NOT EXISTS rejected_expenses NUMERIC(12, 2) DEFAULT 0 NOT NULL,
110
+ ADD COLUMN IF NOT EXISTS expense_claims_count INTEGER DEFAULT 0 NOT NULL;
111
+
112
+ -- Add comments
113
+ COMMENT ON COLUMN timesheets.total_expenses IS 'Total expense amount claimed this day';
114
+ COMMENT ON COLUMN timesheets.approved_expenses IS 'Approved expense amount';
115
+ COMMENT ON COLUMN timesheets.pending_expenses IS 'Pending approval amount';
116
+ COMMENT ON COLUMN timesheets.rejected_expenses IS 'Rejected expense amount';
117
+ COMMENT ON COLUMN timesheets.expense_claims_count IS 'Number of expense claims submitted';
118
+
119
+ -- =====================================================
120
+ -- PART 5: Add Indexes for Performance
121
+ -- =====================================================
122
+
123
+ -- Index for timesheet queries by user and date
124
+ CREATE INDEX IF NOT EXISTS idx_timesheets_user_date ON timesheets(user_id, work_date);
125
+
126
+ -- Index for timesheet queries by project and date
127
+ CREATE INDEX IF NOT EXISTS idx_timesheets_project_date ON timesheets(project_id, work_date);
128
+
129
+ -- Index for payroll queries (unpaid timesheets)
130
+ CREATE INDEX IF NOT EXISTS idx_timesheets_payroll_pending
131
+ ON timesheets(is_payroll_generated, work_date)
132
+ WHERE is_payroll_generated = FALSE AND deleted_at IS NULL;
133
+
134
+ -- =====================================================
135
+ -- VERIFICATION QUERIES
136
+ -- =====================================================
137
+
138
+ -- Verify version column exists
139
+ SELECT column_name, data_type, column_default
140
+ FROM information_schema.columns
141
+ WHERE table_name = 'timesheets' AND column_name = 'version';
142
+
143
+ -- Verify unique constraint
144
+ SELECT constraint_name, constraint_type
145
+ FROM information_schema.table_constraints
146
+ WHERE table_name = 'timesheets' AND constraint_name = 'uq_timesheets_user_project_date';
147
+
148
+ -- Verify sync tracking columns on ticket_assignments
149
+ SELECT column_name, data_type
150
+ FROM information_schema.columns
151
+ WHERE table_name = 'ticket_assignments' AND column_name IN ('timesheet_synced', 'timesheet_synced_at');
152
+
153
+ -- Verify sync tracking columns on ticket_expenses
154
+ SELECT column_name, data_type
155
+ FROM information_schema.columns
156
+ WHERE table_name = 'ticket_expenses' AND column_name IN ('timesheet_synced', 'timesheet_synced_at');
157
+
158
+ -- Verify expense columns on timesheets
159
+ SELECT column_name, data_type
160
+ FROM information_schema.columns
161
+ WHERE table_name = 'timesheets' AND column_name IN ('total_expenses', 'approved_expenses', 'pending_expenses', 'rejected_expenses', 'expense_claims_count');
162
+
supabase/migrations/20241212_simplify_compensation_structure.sql ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- ============================================
2
+ -- COMPENSATION STRUCTURE SIMPLIFICATION
3
+ -- ============================================
4
+ -- This migration simplifies the compensation structure for global use
5
+ -- Removes redundant fields and creates a clean, simple system
6
+ --
7
+ -- Changes:
8
+ -- 1. project_roles: Remove redundant fields, add new simplified fields
9
+ -- 2. user_payroll: Remove redundant fields, add base_earnings
10
+ -- 3. Update enums for compensation_type and add rate_period
11
+ -- 4. Update constraints
12
+ --
13
+ -- Author: SwiftOps Team
14
+ -- Date: 2024-12-12
15
+ -- ============================================
16
+
17
+ BEGIN;
18
+
19
+ -- ============================================
20
+ -- STEP 1: Update CompensationType Enum
21
+ -- ============================================
22
+
23
+ -- Rename old enum (it's called 'compensationtype' in Supabase, not 'compensation_type')
24
+ ALTER TYPE compensationtype RENAME TO compensationtype_old;
25
+
26
+ -- Create new enum with new values
27
+ CREATE TYPE compensationtype AS ENUM (
28
+ 'FIXED_RATE', -- Time-based: hourly, daily, weekly, monthly
29
+ 'PER_UNIT', -- Work-based: per-ticket, per-job
30
+ 'COMMISSION', -- Percentage-based: sales commission
31
+ 'FIXED_PLUS_COMMISSION' -- Hybrid: base + commission
32
+ );
33
+
34
+ -- ============================================
35
+ -- STEP 2: Create RatePeriod Enum
36
+ -- ============================================
37
+
38
+ CREATE TYPE rate_period AS ENUM (
39
+ 'HOUR',
40
+ 'DAY',
41
+ 'WEEK',
42
+ 'MONTH'
43
+ );
44
+
45
+ -- ============================================
46
+ -- STEP 3: Update project_roles Table
47
+ -- ============================================
48
+
49
+ -- Add new simplified fields
50
+ ALTER TABLE project_roles
51
+ ADD COLUMN base_rate NUMERIC(12, 2),
52
+ ADD COLUMN rate_period rate_period,
53
+ ADD COLUMN per_unit_rate NUMERIC(12, 2),
54
+ ADD COLUMN temp_compensation_type compensationtype;
55
+
56
+ -- Migrate existing data to new structure
57
+ -- This converts old compensation types to new ones
58
+ -- Note: compensation_type column still exists with old enum type (compensationtype_old)
59
+ UPDATE project_roles
60
+ SET
61
+ temp_compensation_type = CASE
62
+ -- Map old per_day/per_week/per_ticket to new types
63
+ WHEN compensation_type::text = 'per_day' THEN 'FIXED_RATE'::compensationtype
64
+ WHEN compensation_type::text = 'per_week' THEN 'FIXED_RATE'::compensationtype
65
+ WHEN compensation_type::text = 'per_ticket' THEN 'PER_UNIT'::compensationtype
66
+
67
+ -- Map old legacy types
68
+ WHEN compensation_type::text = 'flat_rate' THEN 'FIXED_RATE'::compensationtype
69
+ WHEN compensation_type::text = 'hourly' THEN 'FIXED_RATE'::compensationtype
70
+ WHEN compensation_type::text = 'commission' THEN 'COMMISSION'::compensationtype
71
+ WHEN compensation_type::text = 'hybrid' THEN 'FIXED_PLUS_COMMISSION'::compensationtype
72
+
73
+ -- Default to FIXED_RATE for custom or unknown
74
+ ELSE 'FIXED_RATE'::compensationtype
75
+ END,
76
+
77
+ base_rate = CASE
78
+ WHEN compensation_type::text = 'per_day' THEN daily_rate
79
+ WHEN compensation_type::text = 'per_week' THEN weekly_rate
80
+ WHEN compensation_type::text = 'flat_rate' THEN flat_rate_amount
81
+ WHEN compensation_type::text = 'hourly' THEN hourly_rate
82
+ WHEN compensation_type::text = 'hybrid' THEN base_amount
83
+ ELSE NULL
84
+ END,
85
+
86
+ rate_period = CASE
87
+ WHEN compensation_type::text = 'per_day' THEN 'DAY'::rate_period
88
+ WHEN compensation_type::text = 'per_week' THEN 'WEEK'::rate_period
89
+ WHEN compensation_type::text = 'flat_rate' THEN 'WEEK'::rate_period
90
+ WHEN compensation_type::text = 'hourly' THEN 'HOUR'::rate_period
91
+ WHEN compensation_type::text = 'hybrid' THEN 'DAY'::rate_period
92
+ ELSE NULL
93
+ END,
94
+
95
+ per_unit_rate = CASE
96
+ WHEN compensation_type::text = 'per_ticket' THEN per_ticket_rate
97
+ ELSE NULL
98
+ END;
99
+
100
+ -- Drop old compensation_type column and rename temp
101
+ ALTER TABLE project_roles DROP COLUMN compensation_type;
102
+ ALTER TABLE project_roles RENAME COLUMN temp_compensation_type TO compensation_type;
103
+ ALTER TABLE project_roles ALTER COLUMN compensation_type SET NOT NULL;
104
+
105
+ -- Drop old redundant fields
106
+ ALTER TABLE project_roles
107
+ DROP COLUMN IF EXISTS flat_rate_amount,
108
+ DROP COLUMN IF EXISTS base_amount,
109
+ DROP COLUMN IF EXISTS bonus_percentage,
110
+ DROP COLUMN IF EXISTS hourly_rate,
111
+ DROP COLUMN IF EXISTS daily_rate,
112
+ DROP COLUMN IF EXISTS weekly_rate,
113
+ DROP COLUMN IF EXISTS per_ticket_rate;
114
+
115
+ -- Add check constraints for field usage
116
+ ALTER TABLE project_roles
117
+ ADD CONSTRAINT chk_fixed_rate_fields
118
+ CHECK (
119
+ compensation_type != 'FIXED_RATE' OR
120
+ (base_rate IS NOT NULL AND base_rate >= 0 AND rate_period IS NOT NULL)
121
+ ),
122
+ ADD CONSTRAINT chk_per_unit_fields
123
+ CHECK (
124
+ compensation_type != 'PER_UNIT' OR
125
+ (per_unit_rate IS NOT NULL AND per_unit_rate >= 0)
126
+ ),
127
+ ADD CONSTRAINT chk_commission_fields
128
+ CHECK (
129
+ compensation_type != 'COMMISSION' OR
130
+ (commission_percentage IS NOT NULL AND commission_percentage >= 0 AND commission_percentage <= 100)
131
+ ),
132
+ ADD CONSTRAINT chk_fixed_plus_commission_fields
133
+ CHECK (
134
+ compensation_type != 'FIXED_PLUS_COMMISSION' OR
135
+ (base_rate IS NOT NULL AND base_rate >= 0 AND rate_period IS NOT NULL AND
136
+ commission_percentage IS NOT NULL AND commission_percentage >= 0 AND commission_percentage <= 100)
137
+ );
138
+
139
+ -- Drop old enum type
140
+ DROP TYPE compensationtype_old;
141
+
142
+ -- ============================================
143
+ -- STEP 4: Update user_payroll Table
144
+ -- ============================================
145
+
146
+ -- Add new base_earnings field
147
+ ALTER TABLE user_payroll
148
+ ADD COLUMN base_earnings NUMERIC(12, 2) DEFAULT 0 NOT NULL;
149
+
150
+ -- Migrate existing data: base_earnings = flat_rate_amount + ticket_earnings
151
+ UPDATE user_payroll
152
+ SET base_earnings = COALESCE(flat_rate_amount, 0) + COALESCE(ticket_earnings, 0);
153
+
154
+ -- Drop old redundant fields
155
+ ALTER TABLE user_payroll
156
+ DROP COLUMN IF EXISTS hours_worked,
157
+ DROP COLUMN IF EXISTS flat_rate_amount,
158
+ DROP COLUMN IF EXISTS ticket_earnings;
159
+
160
+ -- Drop old constraints that referenced removed columns
161
+ ALTER TABLE user_payroll
162
+ DROP CONSTRAINT IF EXISTS chk_positive_hours,
163
+ DROP CONSTRAINT IF EXISTS chk_positive_flat_rate,
164
+ DROP CONSTRAINT IF EXISTS chk_positive_ticket_earnings;
165
+
166
+ -- Add new constraint for base_earnings
167
+ ALTER TABLE user_payroll
168
+ ADD CONSTRAINT chk_positive_base_earnings CHECK (base_earnings >= 0);
169
+
170
+ COMMIT;
171
+
172
+ -- ============================================
173
+ -- VERIFICATION QUERIES
174
+ -- ============================================
175
+ -- Run these after migration to verify success:
176
+ --
177
+ -- SELECT compensation_type, COUNT(*) FROM project_roles GROUP BY compensation_type;
178
+ -- SELECT * FROM project_roles LIMIT 5;
179
+ -- SELECT * FROM user_payroll LIMIT 5;
180
+