MukeshKapoor25 commited on
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 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)