Spaces:
Running
Running
Commit ·
2d6d369
1
Parent(s): e343d7e
feat(service-catalogue): implement complete CRUD API for service catalogue management
Browse files- Add service catalogue module with complete CRUD operations (create, read, update, delete)
- Implement service catalogue controllers with endpoints for managing services
- Add service catalogue schemas for request/response validation
- Implement service catalogue business logic and database operations
- Add comprehensive API documentation with quick reference guide
- Support service filtering by status, category, and merchant
- Implement soft delete functionality (archive services)
- Add projection support for optimized list queries
- Integrate service catalogue router into main application
- Include audit trail tracking for all service operations
- SERVICE_CATALOGUE_API_QUICK_REF.md +308 -0
- app/main.py +2 -0
- app/service_catalogue/__init__.py +3 -0
- app/service_catalogue/controllers/__init__.py +3 -0
- app/service_catalogue/controllers/router.py +363 -0
- app/service_catalogue/schemas/__init__.py +3 -0
- app/service_catalogue/schemas/schema.py +74 -0
- app/service_catalogue/services/__init__.py +3 -0
- app/service_catalogue/services/service.py +295 -0
SERVICE_CATALOGUE_API_QUICK_REF.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Service Catalogue API - Quick Reference
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
Complete CRUD API for managing service catalogue in SCM microservice.
|
| 5 |
+
|
| 6 |
+
**Collection**: `scm_service_catalogue` (MongoDB)
|
| 7 |
+
**Base Path**: `/scm/catalogue/services`
|
| 8 |
+
**Module Permission**: `catalogue`
|
| 9 |
+
|
| 10 |
+
## Endpoints
|
| 11 |
+
|
| 12 |
+
### 1. Create Service
|
| 13 |
+
```http
|
| 14 |
+
POST /scm/catalogue/services
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
**Permission**: `catalogue.create`
|
| 18 |
+
|
| 19 |
+
**Request Body**:
|
| 20 |
+
```json
|
| 21 |
+
{
|
| 22 |
+
"merchant_id": "string (optional, from token if not provided)",
|
| 23 |
+
"name": "Hair Cut",
|
| 24 |
+
"code": "SVC001",
|
| 25 |
+
"category_id": "cat_123",
|
| 26 |
+
"category_name": "Hair Services",
|
| 27 |
+
"description": "Professional hair cutting service",
|
| 28 |
+
"duration_mins": 30,
|
| 29 |
+
"pricing": {
|
| 30 |
+
"price": 500.00,
|
| 31 |
+
"gst_rate": 18.0,
|
| 32 |
+
"currency": "INR"
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
**Response**: `ServiceResponse` (201 Created)
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
### 2. List Services (with Projection Support)
|
| 42 |
+
```http
|
| 43 |
+
POST /scm/catalogue/services/list
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
**Permission**: `catalogue.view`
|
| 47 |
+
|
| 48 |
+
**Request Body**:
|
| 49 |
+
```json
|
| 50 |
+
{
|
| 51 |
+
"merchant_id": "string (optional, from token if not provided)",
|
| 52 |
+
"status": "active",
|
| 53 |
+
"category": "cat_123",
|
| 54 |
+
"skip": 0,
|
| 55 |
+
"limit": 100,
|
| 56 |
+
"projection_list": ["service_id", "name", "code", "price", "status"]
|
| 57 |
+
}
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
**Response**:
|
| 61 |
+
```json
|
| 62 |
+
{
|
| 63 |
+
"items": [
|
| 64 |
+
{
|
| 65 |
+
"service_id": "uuid",
|
| 66 |
+
"name": "Hair Cut",
|
| 67 |
+
"code": "SVC001",
|
| 68 |
+
"price": 500.00,
|
| 69 |
+
"status": "active"
|
| 70 |
+
}
|
| 71 |
+
],
|
| 72 |
+
"total": 1
|
| 73 |
+
}
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
**Projection Fields**:
|
| 77 |
+
- `service_id` - Service UUID
|
| 78 |
+
- `merchant_id` - Merchant UUID
|
| 79 |
+
- `name` - Service name
|
| 80 |
+
- `code` - Service code
|
| 81 |
+
- `category` - Category object
|
| 82 |
+
- `description` - Service description
|
| 83 |
+
- `duration_mins` - Duration in minutes
|
| 84 |
+
- `pricing` - Full pricing object
|
| 85 |
+
- `price` - Just the price value (nested field)
|
| 86 |
+
- `status` - Service status
|
| 87 |
+
- `sort_order` - Sort order
|
| 88 |
+
- `meta` - Audit information
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
### 3. Get Service by ID
|
| 93 |
+
```http
|
| 94 |
+
GET /scm/catalogue/services/{service_id}
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
**Permission**: `catalogue.view`
|
| 98 |
+
|
| 99 |
+
**Response**: `ServiceResponse`
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
### 4. Update Service
|
| 104 |
+
```http
|
| 105 |
+
PUT /scm/catalogue/services/{service_id}
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
**Permission**: `catalogue.update`
|
| 109 |
+
|
| 110 |
+
**Request Body** (all fields optional):
|
| 111 |
+
```json
|
| 112 |
+
{
|
| 113 |
+
"name": "Premium Hair Cut",
|
| 114 |
+
"code": "SVC001A",
|
| 115 |
+
"category_id": "cat_123",
|
| 116 |
+
"category_name": "Hair Services",
|
| 117 |
+
"description": "Updated description",
|
| 118 |
+
"duration_mins": 45,
|
| 119 |
+
"pricing": {
|
| 120 |
+
"price": 600.00,
|
| 121 |
+
"gst_rate": 18.0
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
**Response**: `ServiceResponse`
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
### 5. Update Service Status
|
| 131 |
+
```http
|
| 132 |
+
PATCH /scm/catalogue/services/{service_id}/status
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
**Permission**: `catalogue.update`
|
| 136 |
+
|
| 137 |
+
**Request Body**:
|
| 138 |
+
```json
|
| 139 |
+
{
|
| 140 |
+
"status": "inactive"
|
| 141 |
+
}
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
**Valid Status Values**: `active`, `inactive`, `archived`
|
| 145 |
+
|
| 146 |
+
**Response**: `ServiceResponse`
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
### 6. Delete Service (Soft Delete)
|
| 151 |
+
```http
|
| 152 |
+
DELETE /scm/catalogue/services/{service_id}
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
**Permission**: `catalogue.delete`
|
| 156 |
+
|
| 157 |
+
**Response**: `ServiceResponse` (status set to "archived")
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## Response Schema
|
| 162 |
+
|
| 163 |
+
### ServiceResponse
|
| 164 |
+
```json
|
| 165 |
+
{
|
| 166 |
+
"service_id": "uuid",
|
| 167 |
+
"merchant_id": "uuid",
|
| 168 |
+
"name": "Hair Cut",
|
| 169 |
+
"code": "SVC001",
|
| 170 |
+
"category": {
|
| 171 |
+
"id": "cat_123",
|
| 172 |
+
"name": "Hair Services"
|
| 173 |
+
},
|
| 174 |
+
"description": "Professional hair cutting service",
|
| 175 |
+
"duration_mins": 30,
|
| 176 |
+
"pricing": {
|
| 177 |
+
"price": 500.00,
|
| 178 |
+
"gst_rate": 18.0,
|
| 179 |
+
"currency": "INR"
|
| 180 |
+
},
|
| 181 |
+
"status": "active",
|
| 182 |
+
"sort_order": 0,
|
| 183 |
+
"meta": {
|
| 184 |
+
"created_by": "admin",
|
| 185 |
+
"created_by_id": "usr_123",
|
| 186 |
+
"created_at": "2024-01-01T00:00:00Z",
|
| 187 |
+
"updated_by": "manager",
|
| 188 |
+
"updated_by_id": "usr_456",
|
| 189 |
+
"updated_at": "2024-01-02T00:00:00Z"
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## Business Rules
|
| 197 |
+
|
| 198 |
+
1. **Unique Code**: Service code must be unique per merchant
|
| 199 |
+
2. **Archived Services**: Cannot update archived services
|
| 200 |
+
3. **Soft Delete**: Delete operation sets status to "archived"
|
| 201 |
+
4. **Merchant Context**: merchant_id from JWT token if not provided
|
| 202 |
+
5. **Audit Trail**: All operations tracked with user_id and username
|
| 203 |
+
6. **Projection Support**: List endpoint supports field projection for performance
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
## Example Usage
|
| 208 |
+
|
| 209 |
+
### Create a Service
|
| 210 |
+
```bash
|
| 211 |
+
curl -X POST "http://localhost:8000/scm/catalogue/services" \
|
| 212 |
+
-H "Authorization: Bearer $TOKEN" \
|
| 213 |
+
-H "Content-Type: application/json" \
|
| 214 |
+
-d '{
|
| 215 |
+
"name": "Hair Cut",
|
| 216 |
+
"code": "SVC001",
|
| 217 |
+
"category_id": "cat_hair",
|
| 218 |
+
"category_name": "Hair Services",
|
| 219 |
+
"duration_mins": 30,
|
| 220 |
+
"pricing": {
|
| 221 |
+
"price": 500.00,
|
| 222 |
+
"gst_rate": 18.0
|
| 223 |
+
}
|
| 224 |
+
}'
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
### List Services with Projection
|
| 228 |
+
```bash
|
| 229 |
+
curl -X POST "http://localhost:8000/scm/catalogue/services/list" \
|
| 230 |
+
-H "Authorization: Bearer $TOKEN" \
|
| 231 |
+
-H "Content-Type: application/json" \
|
| 232 |
+
-d '{
|
| 233 |
+
"status": "active",
|
| 234 |
+
"skip": 0,
|
| 235 |
+
"limit": 10,
|
| 236 |
+
"projection_list": ["service_id", "name", "code", "price"]
|
| 237 |
+
}'
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
### Update Service
|
| 241 |
+
```bash
|
| 242 |
+
curl -X PUT "http://localhost:8000/scm/catalogue/services/{service_id}" \
|
| 243 |
+
-H "Authorization: Bearer $TOKEN" \
|
| 244 |
+
-H "Content-Type: application/json" \
|
| 245 |
+
-d '{
|
| 246 |
+
"name": "Premium Hair Cut",
|
| 247 |
+
"pricing": {
|
| 248 |
+
"price": 600.00,
|
| 249 |
+
"gst_rate": 18.0
|
| 250 |
+
}
|
| 251 |
+
}'
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
### Archive Service
|
| 255 |
+
```bash
|
| 256 |
+
curl -X DELETE "http://localhost:8000/scm/catalogue/services/{service_id}" \
|
| 257 |
+
-H "Authorization: Bearer $TOKEN"
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
---
|
| 261 |
+
|
| 262 |
+
## Error Responses
|
| 263 |
+
|
| 264 |
+
### 400 Bad Request
|
| 265 |
+
```json
|
| 266 |
+
{
|
| 267 |
+
"detail": "Service code 'SVC001' already exists for this merchant"
|
| 268 |
+
}
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
### 403 Forbidden
|
| 272 |
+
```json
|
| 273 |
+
{
|
| 274 |
+
"detail": "Access denied. Required permission: catalogue.create"
|
| 275 |
+
}
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
### 404 Not Found
|
| 279 |
+
```json
|
| 280 |
+
{
|
| 281 |
+
"detail": "Service not found"
|
| 282 |
+
}
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
### 422 Validation Error
|
| 286 |
+
```json
|
| 287 |
+
{
|
| 288 |
+
"success": false,
|
| 289 |
+
"error": "Validation Error",
|
| 290 |
+
"errors": [
|
| 291 |
+
{
|
| 292 |
+
"field": "pricing -> price",
|
| 293 |
+
"message": "ensure this value is greater than or equal to 0",
|
| 294 |
+
"type": "value_error.number.not_ge"
|
| 295 |
+
}
|
| 296 |
+
]
|
| 297 |
+
}
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## Notes
|
| 303 |
+
|
| 304 |
+
- All endpoints require JWT authentication
|
| 305 |
+
- Permissions are checked via SCM access roles
|
| 306 |
+
- MongoDB is the source of truth
|
| 307 |
+
- Projection list reduces payload size (50-90% reduction possible)
|
| 308 |
+
- Status transitions: active ↔ inactive → archived (archived is final)
|
app/main.py
CHANGED
|
@@ -43,6 +43,7 @@ from app.dashboard.controllers.dashboard_router import router as dashboard_layou
|
|
| 43 |
from app.service_partners.controllers.router import router as service_partners_router
|
| 44 |
from app.spa_orders.controllers.router import router as spa_orders_router
|
| 45 |
from app.service_professionals.controllers.router import router as service_professionals_router
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
# Initialize logging first
|
|
@@ -162,6 +163,7 @@ app.include_router(documents_router)
|
|
| 162 |
app.include_router(service_partners_router)
|
| 163 |
app.include_router(spa_orders_router)
|
| 164 |
app.include_router(service_professionals_router)
|
|
|
|
| 165 |
|
| 166 |
# Dashboard routers
|
| 167 |
app.include_router(dashboard_widget_router, prefix="/dashboard", tags=["Dashboard Widgets"])
|
|
|
|
| 43 |
from app.service_partners.controllers.router import router as service_partners_router
|
| 44 |
from app.spa_orders.controllers.router import router as spa_orders_router
|
| 45 |
from app.service_professionals.controllers.router import router as service_professionals_router
|
| 46 |
+
from app.service_catalogue.controllers.router import router as service_catalogue_router
|
| 47 |
|
| 48 |
|
| 49 |
# Initialize logging first
|
|
|
|
| 163 |
app.include_router(service_partners_router)
|
| 164 |
app.include_router(spa_orders_router)
|
| 165 |
app.include_router(service_professionals_router)
|
| 166 |
+
app.include_router(service_catalogue_router)
|
| 167 |
|
| 168 |
# Dashboard routers
|
| 169 |
app.include_router(dashboard_widget_router, prefix="/dashboard", tags=["Dashboard Widgets"])
|
app/service_catalogue/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Catalogue module for SCM.
|
| 3 |
+
"""
|
app/service_catalogue/controllers/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Catalogue controllers.
|
| 3 |
+
"""
|
app/service_catalogue/controllers/router.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Catalogue API router - FastAPI endpoints for service catalogue CRUD.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
| 6 |
+
|
| 7 |
+
from app.core.logging import get_logger
|
| 8 |
+
from app.core.utils import format_meta_field
|
| 9 |
+
from app.dependencies.auth import TokenUser
|
| 10 |
+
from app.dependencies.scm_permissions import require_scm_permission
|
| 11 |
+
from app.service_catalogue.schemas.schema import (
|
| 12 |
+
CreateServiceRequest,
|
| 13 |
+
UpdateServiceRequest,
|
| 14 |
+
UpdateStatusRequest,
|
| 15 |
+
ServiceResponse,
|
| 16 |
+
ListServicesRequest,
|
| 17 |
+
ListServicesResponse,
|
| 18 |
+
)
|
| 19 |
+
from app.service_catalogue.services.service import (
|
| 20 |
+
create_service,
|
| 21 |
+
get_service,
|
| 22 |
+
list_services,
|
| 23 |
+
update_service,
|
| 24 |
+
update_status,
|
| 25 |
+
delete_service,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
logger = get_logger(__name__)
|
| 29 |
+
|
| 30 |
+
router = APIRouter(
|
| 31 |
+
prefix="/scm/catalogue/services",
|
| 32 |
+
tags=["service-catalogue"],
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.post("", response_model=ServiceResponse, status_code=status.HTTP_201_CREATED)
|
| 37 |
+
async def create_service_endpoint(
|
| 38 |
+
req: CreateServiceRequest,
|
| 39 |
+
current_user: TokenUser = Depends(require_scm_permission("catalogue", "create"))
|
| 40 |
+
):
|
| 41 |
+
try:
|
| 42 |
+
merchant_id = req.merchant_id
|
| 43 |
+
if merchant_id is None:
|
| 44 |
+
if not current_user.merchant_id:
|
| 45 |
+
raise HTTPException(status_code=400, detail="merchant_id must be provided in request or token")
|
| 46 |
+
merchant_id = current_user.merchant_id
|
| 47 |
+
|
| 48 |
+
req.created_by_username = current_user.username
|
| 49 |
+
|
| 50 |
+
doc = await create_service(
|
| 51 |
+
merchant_id=merchant_id,
|
| 52 |
+
name=req.name,
|
| 53 |
+
code=req.code,
|
| 54 |
+
category_id=req.category_id,
|
| 55 |
+
category_name=req.category_name,
|
| 56 |
+
description=req.description,
|
| 57 |
+
duration_mins=req.duration_mins,
|
| 58 |
+
price=req.pricing.price,
|
| 59 |
+
gst_rate=req.pricing.gst_rate,
|
| 60 |
+
user_id=current_user.user_id,
|
| 61 |
+
username=current_user.username
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
logger.info(
|
| 65 |
+
"Service created successfully",
|
| 66 |
+
extra={
|
| 67 |
+
"operation": "create_service",
|
| 68 |
+
"service_id": str(doc["_id"]),
|
| 69 |
+
"merchant_id": str(merchant_id),
|
| 70 |
+
"user_id": str(current_user.user_id) if getattr(current_user, "user_id", None) else None
|
| 71 |
+
}
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return _to_response(doc)
|
| 75 |
+
except HTTPException:
|
| 76 |
+
raise
|
| 77 |
+
except ValueError as e:
|
| 78 |
+
logger.warning(
|
| 79 |
+
"Create service validation failed",
|
| 80 |
+
extra={
|
| 81 |
+
"operation": "create_service",
|
| 82 |
+
"error": str(e),
|
| 83 |
+
"merchant_id": str(merchant_id) if 'merchant_id' in locals() else None
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(
|
| 89 |
+
"Create service failed",
|
| 90 |
+
extra={
|
| 91 |
+
"operation": "create_service",
|
| 92 |
+
"error": str(e),
|
| 93 |
+
"error_type": type(e).__name__,
|
| 94 |
+
"merchant_id": str(merchant_id) if 'merchant_id' in locals() else None
|
| 95 |
+
},
|
| 96 |
+
exc_info=True
|
| 97 |
+
)
|
| 98 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create service")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.post("/list", response_model=ListServicesResponse)
|
| 102 |
+
async def list_services_endpoint(
|
| 103 |
+
req: ListServicesRequest,
|
| 104 |
+
current_user: TokenUser = Depends(require_scm_permission("catalogue", "view"))
|
| 105 |
+
):
|
| 106 |
+
try:
|
| 107 |
+
merchant_id = req.merchant_id
|
| 108 |
+
if merchant_id is None:
|
| 109 |
+
if not current_user.merchant_id:
|
| 110 |
+
raise HTTPException(status_code=400, detail="merchant_id must be provided in request or token")
|
| 111 |
+
merchant_id = current_user.merchant_id
|
| 112 |
+
|
| 113 |
+
items, total = await list_services(
|
| 114 |
+
merchant_id=merchant_id,
|
| 115 |
+
status=req.status,
|
| 116 |
+
category=req.category,
|
| 117 |
+
skip=req.skip,
|
| 118 |
+
limit=req.limit,
|
| 119 |
+
projection_list=req.projection_list
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
logger.info(
|
| 123 |
+
"Services listed",
|
| 124 |
+
extra={
|
| 125 |
+
"operation": "list_services",
|
| 126 |
+
"count": total,
|
| 127 |
+
"merchant_id": str(merchant_id),
|
| 128 |
+
"user_id": str(current_user.user_id) if getattr(current_user, "user_id", None) else None
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return ListServicesResponse(
|
| 133 |
+
items=items,
|
| 134 |
+
total=total
|
| 135 |
+
)
|
| 136 |
+
except HTTPException:
|
| 137 |
+
raise
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(
|
| 140 |
+
"List services failed",
|
| 141 |
+
extra={
|
| 142 |
+
"operation": "list_services",
|
| 143 |
+
"error": str(e),
|
| 144 |
+
"error_type": type(e).__name__,
|
| 145 |
+
"merchant_id": str(merchant_id) if 'merchant_id' in locals() else None
|
| 146 |
+
},
|
| 147 |
+
exc_info=True
|
| 148 |
+
)
|
| 149 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to list services")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@router.get("/{service_id}", response_model=ServiceResponse)
|
| 153 |
+
async def get_service_endpoint(
|
| 154 |
+
service_id: str,
|
| 155 |
+
current_user: TokenUser = Depends(require_scm_permission("catalogue", "view"))
|
| 156 |
+
):
|
| 157 |
+
try:
|
| 158 |
+
doc = await get_service(service_id)
|
| 159 |
+
if not doc:
|
| 160 |
+
raise HTTPException(status_code=404, detail="Service not found")
|
| 161 |
+
return _to_response(doc)
|
| 162 |
+
except HTTPException:
|
| 163 |
+
raise
|
| 164 |
+
except Exception as e:
|
| 165 |
+
logger.error(
|
| 166 |
+
"Get service failed",
|
| 167 |
+
extra={
|
| 168 |
+
"operation": "get_service",
|
| 169 |
+
"service_id": service_id,
|
| 170 |
+
"error": str(e),
|
| 171 |
+
"error_type": type(e).__name__
|
| 172 |
+
},
|
| 173 |
+
exc_info=True
|
| 174 |
+
)
|
| 175 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get service")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.put("/{service_id}", response_model=ServiceResponse)
|
| 179 |
+
async def update_service_endpoint(
|
| 180 |
+
service_id: str,
|
| 181 |
+
req: UpdateServiceRequest,
|
| 182 |
+
current_user: TokenUser = Depends(require_scm_permission("catalogue", "update"))
|
| 183 |
+
):
|
| 184 |
+
try:
|
| 185 |
+
req.updated_by_username = current_user.username
|
| 186 |
+
|
| 187 |
+
update_data = {
|
| 188 |
+
"name": req.name,
|
| 189 |
+
"code": req.code,
|
| 190 |
+
"category_id": req.category_id,
|
| 191 |
+
"category_name": req.category_name,
|
| 192 |
+
"description": req.description,
|
| 193 |
+
"duration_mins": req.duration_mins,
|
| 194 |
+
"pricing": req.pricing,
|
| 195 |
+
"user_id": current_user.user_id,
|
| 196 |
+
"username": current_user.username
|
| 197 |
+
}
|
| 198 |
+
update_data = {k: v for k, v in update_data.items() if v is not None}
|
| 199 |
+
if "user_id" not in update_data or "username" not in update_data:
|
| 200 |
+
update_data["user_id"] = current_user.user_id
|
| 201 |
+
update_data["username"] = current_user.username
|
| 202 |
+
if not any(k not in ["user_id", "username"] for k in update_data.keys()):
|
| 203 |
+
raise HTTPException(
|
| 204 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 205 |
+
detail="No fields provided for update"
|
| 206 |
+
)
|
| 207 |
+
doc = await update_service(
|
| 208 |
+
service_id=service_id,
|
| 209 |
+
**update_data
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
logger.info(
|
| 213 |
+
"Service updated",
|
| 214 |
+
extra={
|
| 215 |
+
"operation": "update_service",
|
| 216 |
+
"service_id": service_id,
|
| 217 |
+
"user_id": str(current_user.user_id) if getattr(current_user, "user_id", None) else None
|
| 218 |
+
}
|
| 219 |
+
)
|
| 220 |
+
return _to_response(doc)
|
| 221 |
+
except HTTPException:
|
| 222 |
+
raise
|
| 223 |
+
except ValueError as e:
|
| 224 |
+
logger.warning(
|
| 225 |
+
"Update service validation failed",
|
| 226 |
+
extra={
|
| 227 |
+
"operation": "update_service",
|
| 228 |
+
"service_id": service_id,
|
| 229 |
+
"error": str(e)
|
| 230 |
+
}
|
| 231 |
+
)
|
| 232 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error(
|
| 235 |
+
"Update service failed",
|
| 236 |
+
extra={
|
| 237 |
+
"operation": "update_service",
|
| 238 |
+
"service_id": service_id,
|
| 239 |
+
"error": str(e),
|
| 240 |
+
"error_type": type(e).__name__
|
| 241 |
+
},
|
| 242 |
+
exc_info=True
|
| 243 |
+
)
|
| 244 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update service")
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
@router.patch("/{service_id}/status", response_model=ServiceResponse)
|
| 248 |
+
async def update_status_endpoint(
|
| 249 |
+
service_id: str,
|
| 250 |
+
req: UpdateStatusRequest,
|
| 251 |
+
current_user: TokenUser = Depends(require_scm_permission("catalogue", "update"))
|
| 252 |
+
):
|
| 253 |
+
try:
|
| 254 |
+
doc = await update_status(
|
| 255 |
+
service_id,
|
| 256 |
+
req.status,
|
| 257 |
+
user_id=current_user.user_id,
|
| 258 |
+
username=current_user.username
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
logger.info(
|
| 262 |
+
"Service status updated",
|
| 263 |
+
extra={
|
| 264 |
+
"operation": "update_status",
|
| 265 |
+
"service_id": service_id,
|
| 266 |
+
"status": req.status,
|
| 267 |
+
"user_id": str(current_user.user_id) if getattr(current_user, "user_id", None) else None
|
| 268 |
+
}
|
| 269 |
+
)
|
| 270 |
+
return _to_response(doc)
|
| 271 |
+
except HTTPException:
|
| 272 |
+
raise
|
| 273 |
+
except ValueError as e:
|
| 274 |
+
logger.warning(
|
| 275 |
+
"Update status validation failed",
|
| 276 |
+
extra={
|
| 277 |
+
"operation": "update_status",
|
| 278 |
+
"service_id": service_id,
|
| 279 |
+
"error": str(e)
|
| 280 |
+
}
|
| 281 |
+
)
|
| 282 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 283 |
+
except Exception as e:
|
| 284 |
+
logger.error(
|
| 285 |
+
"Update status failed",
|
| 286 |
+
extra={
|
| 287 |
+
"operation": "update_status",
|
| 288 |
+
"service_id": service_id,
|
| 289 |
+
"error": str(e),
|
| 290 |
+
"error_type": type(e).__name__
|
| 291 |
+
},
|
| 292 |
+
exc_info=True
|
| 293 |
+
)
|
| 294 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update status")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
@router.delete("/{service_id}", response_model=ServiceResponse)
|
| 298 |
+
async def delete_service_endpoint(
|
| 299 |
+
service_id: str,
|
| 300 |
+
current_user: TokenUser = Depends(require_scm_permission("catalogue", "delete"))
|
| 301 |
+
):
|
| 302 |
+
try:
|
| 303 |
+
doc = await delete_service(
|
| 304 |
+
service_id,
|
| 305 |
+
user_id=current_user.user_id,
|
| 306 |
+
username=current_user.username
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
logger.info(
|
| 310 |
+
"Service deleted",
|
| 311 |
+
extra={
|
| 312 |
+
"operation": "delete_service",
|
| 313 |
+
"service_id": service_id,
|
| 314 |
+
"user_id": str(current_user.user_id) if getattr(current_user, "user_id", None) else None
|
| 315 |
+
}
|
| 316 |
+
)
|
| 317 |
+
return _to_response(doc)
|
| 318 |
+
except HTTPException:
|
| 319 |
+
raise
|
| 320 |
+
except ValueError as e:
|
| 321 |
+
logger.warning(
|
| 322 |
+
"Delete service validation failed",
|
| 323 |
+
extra={
|
| 324 |
+
"operation": "delete_service",
|
| 325 |
+
"service_id": service_id,
|
| 326 |
+
"error": str(e)
|
| 327 |
+
}
|
| 328 |
+
)
|
| 329 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.error(
|
| 332 |
+
"Delete service failed",
|
| 333 |
+
extra={
|
| 334 |
+
"operation": "delete_service",
|
| 335 |
+
"service_id": service_id,
|
| 336 |
+
"error": str(e),
|
| 337 |
+
"error_type": type(e).__name__
|
| 338 |
+
},
|
| 339 |
+
exc_info=True
|
| 340 |
+
)
|
| 341 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete service")
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _to_response(doc: dict) -> ServiceResponse:
|
| 345 |
+
"""Convert MongoDB document to ServiceResponse."""
|
| 346 |
+
doc_copy = doc.copy()
|
| 347 |
+
doc_copy["service_id"] = doc["_id"]
|
| 348 |
+
|
| 349 |
+
formatted = format_meta_field(doc_copy)
|
| 350 |
+
|
| 351 |
+
return ServiceResponse(
|
| 352 |
+
service_id=doc["_id"],
|
| 353 |
+
merchant_id=doc["merchant_id"],
|
| 354 |
+
name=doc["name"],
|
| 355 |
+
code=doc["code"],
|
| 356 |
+
category=doc.get("category"),
|
| 357 |
+
description=doc.get("description"),
|
| 358 |
+
duration_mins=doc["duration_mins"],
|
| 359 |
+
pricing=doc.get("pricing", {}),
|
| 360 |
+
status=doc.get("status", "active"),
|
| 361 |
+
sort_order=doc.get("sort_order", 0),
|
| 362 |
+
meta=formatted["meta"],
|
| 363 |
+
)
|
app/service_catalogue/schemas/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Catalogue schemas.
|
| 3 |
+
"""
|
app/service_catalogue/schemas/schema.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for Service Catalogue API.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional, List, Dict, Any
|
| 5 |
+
from pydantic import BaseModel, Field, constr
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class CategoryInput(BaseModel):
|
| 9 |
+
id: str
|
| 10 |
+
name: str
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Pricing(BaseModel):
|
| 14 |
+
price: float = Field(..., ge=0)
|
| 15 |
+
gst_rate: float = Field(..., ge=0, le=100)
|
| 16 |
+
currency: Optional[constr(pattern="^[A-Z]{3}$")] = "INR"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class CreateServiceRequest(BaseModel):
|
| 20 |
+
merchant_id: Optional[str] = None
|
| 21 |
+
name: str = Field(..., min_length=1, max_length=200)
|
| 22 |
+
code: str = Field(..., min_length=1, max_length=50)
|
| 23 |
+
category_id: Optional[str] = None
|
| 24 |
+
category_name: Optional[str] = None
|
| 25 |
+
description: Optional[str] = None
|
| 26 |
+
duration_mins: int = Field(..., ge=1)
|
| 27 |
+
pricing: Pricing
|
| 28 |
+
created_by_username: Optional[str] = Field(None, description="Username who created - will be set from token")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class UpdateServiceRequest(BaseModel):
|
| 32 |
+
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
| 33 |
+
code: Optional[str] = Field(None, min_length=1, max_length=50)
|
| 34 |
+
category_id: Optional[str] = None
|
| 35 |
+
category_name: Optional[str] = None
|
| 36 |
+
description: Optional[str] = None
|
| 37 |
+
duration_mins: Optional[int] = Field(None, ge=1)
|
| 38 |
+
pricing: Optional[Pricing] = None
|
| 39 |
+
updated_by_username: Optional[str] = Field(None, description="Username who updated - will be set from token")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class UpdateStatusRequest(BaseModel):
|
| 43 |
+
status: str = Field(..., pattern="^(active|inactive|archived)$")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class ServiceResponse(BaseModel):
|
| 47 |
+
service_id: str
|
| 48 |
+
merchant_id: str
|
| 49 |
+
name: str
|
| 50 |
+
code: str
|
| 51 |
+
category: Optional[dict] = None
|
| 52 |
+
description: Optional[str]
|
| 53 |
+
duration_mins: int
|
| 54 |
+
pricing: dict
|
| 55 |
+
status: str
|
| 56 |
+
sort_order: int = 0
|
| 57 |
+
meta: Dict[str, Any] = Field(..., description="Audit information (created_by, created_at, updated_by, updated_at)")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class ListServicesRequest(BaseModel):
|
| 61 |
+
merchant_id: Optional[str] = None
|
| 62 |
+
status: Optional[str] = Field(None, pattern="^(active|inactive|archived)$")
|
| 63 |
+
category: Optional[str] = None
|
| 64 |
+
skip: int = Field(0, ge=0)
|
| 65 |
+
limit: int = Field(100, ge=1, le=1000)
|
| 66 |
+
projection_list: Optional[List[str]] = Field(
|
| 67 |
+
None,
|
| 68 |
+
description="List of fields to include in response"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class ListServicesResponse(BaseModel):
|
| 73 |
+
items: List[dict]
|
| 74 |
+
total: int
|
app/service_catalogue/services/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Catalogue services.
|
| 3 |
+
"""
|
app/service_catalogue/services/service.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Catalogue business logic: MongoDB master.
|
| 3 |
+
"""
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
from typing import Optional, List, Tuple
|
| 6 |
+
from app.service_catalogue.schemas.schema import Pricing
|
| 7 |
+
|
| 8 |
+
from app.core.logging import get_logger
|
| 9 |
+
from app.core.utils import format_meta_field, extract_audit_fields
|
| 10 |
+
from app.nosql import get_database
|
| 11 |
+
|
| 12 |
+
logger = get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
SERVICE_CATALOGUE_COLLECTION = "scm_service_catalogue"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def create_service(
|
| 18 |
+
merchant_id: str,
|
| 19 |
+
name: str,
|
| 20 |
+
code: str,
|
| 21 |
+
category_id: Optional[str],
|
| 22 |
+
category_name: Optional[str],
|
| 23 |
+
description: Optional[str],
|
| 24 |
+
duration_mins: int,
|
| 25 |
+
price: float,
|
| 26 |
+
gst_rate: float,
|
| 27 |
+
user_id: Optional[str] = None,
|
| 28 |
+
username: Optional[str] = None,
|
| 29 |
+
) -> dict:
|
| 30 |
+
"""Create service in MongoDB."""
|
| 31 |
+
db = get_database()
|
| 32 |
+
if db is None:
|
| 33 |
+
raise RuntimeError("MongoDB not available")
|
| 34 |
+
|
| 35 |
+
# Check for duplicate code
|
| 36 |
+
existing = await db[SERVICE_CATALOGUE_COLLECTION].find_one({
|
| 37 |
+
"merchant_id": str(merchant_id),
|
| 38 |
+
"code": code
|
| 39 |
+
})
|
| 40 |
+
if existing:
|
| 41 |
+
raise ValueError(f"Service code '{code}' already exists for this merchant")
|
| 42 |
+
|
| 43 |
+
service_id = str(uuid4())
|
| 44 |
+
|
| 45 |
+
doc = {
|
| 46 |
+
"_id": service_id,
|
| 47 |
+
"merchant_id": str(merchant_id),
|
| 48 |
+
"name": name,
|
| 49 |
+
"code": code,
|
| 50 |
+
"category": {"id": category_id, "name": category_name} if category_id else None,
|
| 51 |
+
"description": description,
|
| 52 |
+
"duration_mins": duration_mins,
|
| 53 |
+
"pricing": {
|
| 54 |
+
"price": price,
|
| 55 |
+
"currency": "INR",
|
| 56 |
+
"gst_rate": gst_rate
|
| 57 |
+
},
|
| 58 |
+
"status": "active",
|
| 59 |
+
"sort_order": 0,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# Add audit fields
|
| 63 |
+
audit_fields = extract_audit_fields(
|
| 64 |
+
user_id=user_id or "system",
|
| 65 |
+
username=username or "system",
|
| 66 |
+
is_update=False
|
| 67 |
+
)
|
| 68 |
+
doc.update(audit_fields)
|
| 69 |
+
|
| 70 |
+
await db[SERVICE_CATALOGUE_COLLECTION].insert_one(doc)
|
| 71 |
+
logger.info(
|
| 72 |
+
f"Created service {service_id} in MongoDB",
|
| 73 |
+
extra={
|
| 74 |
+
"operation": "create_service",
|
| 75 |
+
"service_id": service_id,
|
| 76 |
+
"merchant_id": str(merchant_id)
|
| 77 |
+
}
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
return doc
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def get_service(service_id: str) -> Optional[dict]:
|
| 84 |
+
"""Get service by ID from MongoDB."""
|
| 85 |
+
db = get_database()
|
| 86 |
+
if db is None:
|
| 87 |
+
raise RuntimeError("MongoDB not available")
|
| 88 |
+
return await db[SERVICE_CATALOGUE_COLLECTION].find_one({"_id": service_id})
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def list_services(
|
| 92 |
+
merchant_id: str,
|
| 93 |
+
status: Optional[str] = None,
|
| 94 |
+
category: Optional[str] = None,
|
| 95 |
+
skip: int = 0,
|
| 96 |
+
limit: int = 100,
|
| 97 |
+
projection_list: Optional[List[str]] = None
|
| 98 |
+
) -> Tuple[List[dict], int]:
|
| 99 |
+
"""List services with filters from MongoDB."""
|
| 100 |
+
db = get_database()
|
| 101 |
+
if db is None:
|
| 102 |
+
raise RuntimeError("MongoDB not available")
|
| 103 |
+
|
| 104 |
+
merchant_id_str = str(merchant_id)
|
| 105 |
+
query = {"merchant_id": merchant_id_str}
|
| 106 |
+
if status:
|
| 107 |
+
query["status"] = status
|
| 108 |
+
if category:
|
| 109 |
+
query["category.id"] = category
|
| 110 |
+
|
| 111 |
+
# Build MongoDB projection
|
| 112 |
+
projection_dict = None
|
| 113 |
+
if projection_list:
|
| 114 |
+
projection_dict = {}
|
| 115 |
+
for field in projection_list:
|
| 116 |
+
if field == "service_id":
|
| 117 |
+
continue
|
| 118 |
+
elif field == "price":
|
| 119 |
+
projection_dict["pricing.price"] = 1
|
| 120 |
+
else:
|
| 121 |
+
projection_dict[field] = 1
|
| 122 |
+
|
| 123 |
+
if "service_id" in projection_list:
|
| 124 |
+
projection_dict["_id"] = 1
|
| 125 |
+
else:
|
| 126 |
+
projection_dict["_id"] = 0
|
| 127 |
+
|
| 128 |
+
total = await db[SERVICE_CATALOGUE_COLLECTION].count_documents(query)
|
| 129 |
+
cursor = db[SERVICE_CATALOGUE_COLLECTION].find(query, projection_dict).sort("sort_order", 1).skip(skip).limit(limit)
|
| 130 |
+
items = await cursor.to_list(length=limit)
|
| 131 |
+
|
| 132 |
+
result_items = []
|
| 133 |
+
for item in items:
|
| 134 |
+
if projection_list:
|
| 135 |
+
result_item = {}
|
| 136 |
+
for field in projection_list:
|
| 137 |
+
if field == "service_id" and "_id" in item:
|
| 138 |
+
result_item["service_id"] = item["_id"]
|
| 139 |
+
elif field == "price":
|
| 140 |
+
if "pricing" in item and isinstance(item["pricing"], dict):
|
| 141 |
+
result_item["price"] = item["pricing"].get("price")
|
| 142 |
+
elif "price" in item:
|
| 143 |
+
result_item["price"] = item["price"]
|
| 144 |
+
elif field in item:
|
| 145 |
+
result_item[field] = item[field]
|
| 146 |
+
result_items.append(result_item)
|
| 147 |
+
else:
|
| 148 |
+
item_copy = item.copy()
|
| 149 |
+
item_copy["service_id"] = item["_id"]
|
| 150 |
+
formatted = format_meta_field(item_copy)
|
| 151 |
+
|
| 152 |
+
result_item = {
|
| 153 |
+
"service_id": item["_id"],
|
| 154 |
+
"merchant_id": item["merchant_id"],
|
| 155 |
+
"name": item["name"],
|
| 156 |
+
"code": item["code"],
|
| 157 |
+
"category": item.get("category"),
|
| 158 |
+
"description": item.get("description"),
|
| 159 |
+
"duration_mins": item["duration_mins"],
|
| 160 |
+
"pricing": item.get("pricing", {}),
|
| 161 |
+
"status": item.get("status", "active"),
|
| 162 |
+
"sort_order": item.get("sort_order", 0),
|
| 163 |
+
"meta": formatted["meta"],
|
| 164 |
+
}
|
| 165 |
+
result_items.append(result_item)
|
| 166 |
+
|
| 167 |
+
return result_items, total
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def update_service(
|
| 171 |
+
service_id: str,
|
| 172 |
+
name: Optional[str] = None,
|
| 173 |
+
code: Optional[str] = None,
|
| 174 |
+
category_id: Optional[str] = None,
|
| 175 |
+
category_name: Optional[str] = None,
|
| 176 |
+
description: Optional[str] = None,
|
| 177 |
+
duration_mins: Optional[int] = None,
|
| 178 |
+
pricing: Optional[Pricing] = None,
|
| 179 |
+
user_id: Optional[str] = None,
|
| 180 |
+
username: Optional[str] = None,
|
| 181 |
+
) -> dict:
|
| 182 |
+
"""Update service in MongoDB."""
|
| 183 |
+
db = get_database()
|
| 184 |
+
if db is None:
|
| 185 |
+
raise RuntimeError("MongoDB not available")
|
| 186 |
+
|
| 187 |
+
existing = await db[SERVICE_CATALOGUE_COLLECTION].find_one({"_id": service_id})
|
| 188 |
+
if not existing:
|
| 189 |
+
raise ValueError("Service not found")
|
| 190 |
+
if existing.get("status") == "archived":
|
| 191 |
+
raise ValueError("Cannot update archived service")
|
| 192 |
+
|
| 193 |
+
# Check duplicate code if changing
|
| 194 |
+
if code and code != existing.get("code"):
|
| 195 |
+
dup = await db[SERVICE_CATALOGUE_COLLECTION].find_one({
|
| 196 |
+
"merchant_id": existing["merchant_id"],
|
| 197 |
+
"code": code,
|
| 198 |
+
"_id": {"$ne": service_id}
|
| 199 |
+
})
|
| 200 |
+
if dup:
|
| 201 |
+
raise ValueError(f"Service code '{code}' already exists")
|
| 202 |
+
|
| 203 |
+
update_data = {}
|
| 204 |
+
if name:
|
| 205 |
+
update_data["name"] = name
|
| 206 |
+
if code:
|
| 207 |
+
update_data["code"] = code
|
| 208 |
+
if category_id is not None or category_name is not None:
|
| 209 |
+
update_data["category"] = {
|
| 210 |
+
"id": category_id or existing.get("category", {}).get("id"),
|
| 211 |
+
"name": category_name or existing.get("category", {}).get("name")
|
| 212 |
+
}
|
| 213 |
+
if description is not None:
|
| 214 |
+
update_data["description"] = description
|
| 215 |
+
if duration_mins is not None:
|
| 216 |
+
update_data["duration_mins"] = duration_mins
|
| 217 |
+
|
| 218 |
+
if pricing is not None:
|
| 219 |
+
existing_pricing = existing.get("pricing", {}) or {}
|
| 220 |
+
new_pricing = pricing.model_dump(exclude_unset=True)
|
| 221 |
+
existing_pricing.update(new_pricing)
|
| 222 |
+
update_data["pricing"] = existing_pricing
|
| 223 |
+
|
| 224 |
+
audit_fields = extract_audit_fields(
|
| 225 |
+
user_id=user_id or "system",
|
| 226 |
+
username=username or "system",
|
| 227 |
+
is_update=True
|
| 228 |
+
)
|
| 229 |
+
update_data.update(audit_fields)
|
| 230 |
+
|
| 231 |
+
await db[SERVICE_CATALOGUE_COLLECTION].update_one(
|
| 232 |
+
{"_id": service_id},
|
| 233 |
+
{"$set": update_data}
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
updated = await db[SERVICE_CATALOGUE_COLLECTION].find_one({"_id": service_id})
|
| 237 |
+
logger.info(
|
| 238 |
+
f"Updated service {service_id} in MongoDB",
|
| 239 |
+
extra={
|
| 240 |
+
"operation": "update_service",
|
| 241 |
+
"service_id": service_id
|
| 242 |
+
}
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
return updated
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
async def update_status(
|
| 249 |
+
service_id: str,
|
| 250 |
+
status: str,
|
| 251 |
+
user_id: Optional[str] = None,
|
| 252 |
+
username: Optional[str] = None
|
| 253 |
+
) -> dict:
|
| 254 |
+
"""Update service status."""
|
| 255 |
+
db = get_database()
|
| 256 |
+
if db is None:
|
| 257 |
+
raise RuntimeError("MongoDB not available")
|
| 258 |
+
|
| 259 |
+
existing = await db[SERVICE_CATALOGUE_COLLECTION].find_one({"_id": service_id})
|
| 260 |
+
if not existing:
|
| 261 |
+
raise ValueError("Service not found")
|
| 262 |
+
|
| 263 |
+
update_data = {"status": status}
|
| 264 |
+
audit_fields = extract_audit_fields(
|
| 265 |
+
user_id=user_id or "system",
|
| 266 |
+
username=username or "system",
|
| 267 |
+
is_update=True
|
| 268 |
+
)
|
| 269 |
+
update_data.update(audit_fields)
|
| 270 |
+
|
| 271 |
+
await db[SERVICE_CATALOGUE_COLLECTION].update_one(
|
| 272 |
+
{"_id": service_id},
|
| 273 |
+
{"$set": update_data}
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
updated = await db[SERVICE_CATALOGUE_COLLECTION].find_one({"_id": service_id})
|
| 277 |
+
logger.info(
|
| 278 |
+
f"Updated service {service_id} status to {status}",
|
| 279 |
+
extra={
|
| 280 |
+
"operation": "update_status",
|
| 281 |
+
"service_id": service_id,
|
| 282 |
+
"status": status
|
| 283 |
+
}
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
return updated
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
async def delete_service(
|
| 290 |
+
service_id: str,
|
| 291 |
+
user_id: Optional[str] = None,
|
| 292 |
+
username: Optional[str] = None
|
| 293 |
+
) -> dict:
|
| 294 |
+
"""Soft delete service (archive)."""
|
| 295 |
+
return await update_status(service_id, "archived", user_id=user_id, username=username)
|