Spaces:
Sleeping
Sleeping
feat: unified sync notification system, realtime timesheets and payroll, simplified project role compensation
Browse files- docs/dev/NOTIFICATION_SYSTEM.md +448 -0
- docs/features/REALTIME_TIMESHEET_IMPLEMENTATION.md +714 -0
- src/app/api/v1/expenses.py +5 -3
- src/app/api/v1/invoice_generation.py +13 -4
- src/app/api/v1/payroll.py +287 -8
- src/app/api/v1/projects.py +14 -6
- src/app/api/v1/sales_orders.py +7 -4
- src/app/api/v1/tasks.py +7 -4
- src/app/api/v1/ticket_assignments.py +76 -2
- src/app/api/v1/tickets.py +7 -3
- src/app/models/enums.py +24 -12
- src/app/models/project.py +42 -50
- src/app/models/ticket.py +5 -1
- src/app/models/ticket_assignment.py +4 -0
- src/app/models/ticket_expense.py +5 -1
- src/app/models/timesheet.py +12 -2
- src/app/models/user_payroll.py +27 -29
- src/app/schemas/notification.py +4 -0
- src/app/schemas/payroll.py +3 -6
- src/app/schemas/project.py +55 -44
- src/app/services/expense_service.py +133 -59
- src/app/services/invoice_generation_service.py +20 -18
- src/app/services/notification_creator.py +325 -0
- src/app/services/notification_delivery.py +333 -0
- src/app/services/notification_helper.py +232 -0
- src/app/services/payroll_service.py +367 -55
- src/app/services/project_service.py +70 -71
- src/app/services/sales_order_service.py +89 -47
- src/app/services/task_service.py +89 -47
- src/app/services/tende_pay_formatter.py +110 -1
- src/app/services/ticket_assignment_service.py +152 -17
- src/app/services/ticket_completion_service.py +31 -2
- src/app/services/ticket_expense_service.py +191 -65
- src/app/services/ticket_service.py +61 -35
- src/app/services/timesheet_realtime_service.py +404 -0
- supabase/migrations/20241212_realtime_timesheet_updates.sql +162 -0
- 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
|
| 64 |
- Uses compensation rates from project_roles
|
| 65 |
-
|
| 66 |
**Calculation:**
|
| 67 |
-
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
-
|
| 1898 |
-
-
|
| 1899 |
-
-
|
| 1900 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 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 |
-
#
|
| 277 |
-
CheckConstraint(
|
| 278 |
-
'daily_rate IS NULL OR daily_rate >= 0',
|
| 279 |
-
name='chk_positive_daily_rate'
|
| 280 |
-
),
|
| 281 |
CheckConstraint(
|
| 282 |
-
'
|
| 283 |
-
name='
|
| 284 |
),
|
|
|
|
| 285 |
CheckConstraint(
|
| 286 |
-
'
|
| 287 |
-
name='
|
| 288 |
),
|
| 289 |
-
#
|
| 290 |
CheckConstraint(
|
| 291 |
-
'
|
| 292 |
-
name='
|
| 293 |
),
|
|
|
|
| 294 |
CheckConstraint(
|
| 295 |
-
'commission_percentage IS NULL
|
| 296 |
-
name='
|
| 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 (
|
| 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='
|
| 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
|
| 19 |
-
|
| 20 |
-
|
| 21 |
Key Features:
|
| 22 |
-
-
|
| 23 |
-
- Aggregated work summary (tickets,
|
| 24 |
-
-
|
| 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 =
|
| 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
|
| 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 |
-
|
| 60 |
-
|
| 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('
|
| 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.
|
| 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 |
-
|
| 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.
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
"""
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 393 |
-
|
| 394 |
-
]
|
| 395 |
-
|
| 396 |
-
|
| 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 |
-
#
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
# OLD - Backward compatibility
|
| 421 |
-
flat_rate_amount: Optional[Decimal]
|
| 422 |
commission_percentage: Optional[Decimal]
|
| 423 |
-
|
| 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 |
-
#
|
| 104 |
try:
|
| 105 |
-
from app.services.
|
| 106 |
from app.models.user import User
|
| 107 |
-
import
|
| 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 |
-
#
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
except Exception as e:
|
| 130 |
-
logger.error(f"Failed to
|
| 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 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
)
|
| 367 |
else:
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 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 |
-
|
| 444 |
-
|
| 445 |
-
if agent
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 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 =
|
|
|
|
| 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=
|
| 249 |
-
|
| 250 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
| 193 |
"commission_percentage": Decimal('0'),
|
| 194 |
-
"
|
| 195 |
-
"
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
| 204 |
"commission_percentage": Decimal('0'),
|
| 205 |
-
"
|
| 206 |
-
"
|
| 207 |
-
"project_team_id": team_member.id
|
| 208 |
}
|
| 209 |
-
|
| 210 |
return {
|
| 211 |
-
"
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 228 |
tickets_closed: int,
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
| 232 |
"""
|
| 233 |
-
Calculate earnings
|
| 234 |
-
|
| 235 |
-
|
|
|
|
| 236 |
"""
|
| 237 |
-
if
|
| 238 |
-
return Decimal('0')
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 320 |
-
|
| 321 |
-
aggregated_data["
|
| 322 |
-
|
| 323 |
-
compensation["
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 543 |
-
|
| 544 |
-
aggregated_data["
|
| 545 |
-
|
| 546 |
-
compensation["
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 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 |
-
#
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
|
| 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.
|
| 1267 |
-
role.
|
| 1268 |
-
role.
|
| 1269 |
-
role.flat_rate_amount = None
|
| 1270 |
role.commission_percentage = None
|
| 1271 |
-
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1281 |
comp_type = data.compensation_type
|
| 1282 |
-
|
| 1283 |
-
|
| 1284 |
-
|
| 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="
|
| 1295 |
)
|
| 1296 |
-
|
| 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="
|
| 1301 |
)
|
| 1302 |
-
|
| 1303 |
-
elif comp_type == '
|
| 1304 |
-
if not data.
|
| 1305 |
raise HTTPException(
|
| 1306 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1307 |
-
detail="
|
| 1308 |
)
|
| 1309 |
-
|
|
|
|
| 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
|
| 1314 |
)
|
| 1315 |
-
|
| 1316 |
-
|
|
|
|
| 1317 |
raise HTTPException(
|
| 1318 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1319 |
-
detail="
|
| 1320 |
)
|
| 1321 |
-
|
| 1322 |
-
if not data.base_amount or data.base_amount < 0:
|
| 1323 |
raise HTTPException(
|
| 1324 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1325 |
-
detail="
|
| 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
|
| 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 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
| 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 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 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 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 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 |
-
|
| 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 |
-
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 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 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 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 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 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 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 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 |
-
#
|
| 789 |
try:
|
| 790 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
except Exception as e:
|
| 792 |
-
logger.
|
| 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 |
-
#
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
expense
|
| 1571 |
-
|
| 1572 |
-
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 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 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 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 |
+
|