Spaces:
Sleeping
Sleeping
Commit ·
1988d77
1
Parent(s): 85e4fac
feat(logging): implement structured logging across services
Browse filesAdd centralized logging utility with structured logging format
Update all services to use new logger with contextual metadata
Enhance error handling with detailed error logging
Add global exception handlers with proper logging
Include operation context in all log entries
- app/appointments/controllers/router.py +159 -7
- app/appointments/services/service.py +36 -5
- app/catalogue_services/controllers/router.py +175 -12
- app/catalogue_services/services/service.py +43 -6
- app/core/logging.py +13 -0
- app/customers/controllers/router.py +193 -55
- app/customers/services/service.py +48 -6
- app/main.py +82 -38
- app/sales/orders/controllers/router.py +183 -44
- app/sales/orders/services/service.py +77 -18
- app/sales/retail/controllers/router.py +241 -32
- app/sales/retail/services/service.py +50 -8
- app/sales/returns/controllers/router.py +244 -26
- app/sales/returns/services/service.py +419 -253
- app/staff/controllers/router.py +442 -83
- app/staff/services/staff_service.py +112 -16
- app/sync/catalogue_services/sync_service.py +93 -21
- app/sync/customers/sync_service.py +70 -18
- app/sync/staff/sync_service.py +80 -20
- app/sync/sync_service.py +169 -23
- logger_expection.md +399 -0
app/appointments/controllers/router.py
CHANGED
|
@@ -9,6 +9,7 @@ from datetime import datetime
|
|
| 9 |
from fastapi import APIRouter, HTTPException, status, Depends
|
| 10 |
from fastapi.responses import JSONResponse
|
| 11 |
|
|
|
|
| 12 |
from app.dependencies.auth import TokenUser
|
| 13 |
from app.dependencies.pos_permissions import require_pos_permission
|
| 14 |
from app.appointments.schemas.schema import (
|
|
@@ -31,7 +32,7 @@ from app.appointments.services.service import (
|
|
| 31 |
checkout_appointment,
|
| 32 |
)
|
| 33 |
|
| 34 |
-
logger =
|
| 35 |
|
| 36 |
def convert_uuids_to_strings(data):
|
| 37 |
"""Convert UUID objects to strings for JSON serialization"""
|
|
@@ -78,10 +79,35 @@ async def create_appointment_endpoint(
|
|
| 78 |
created_by=req.created_by,
|
| 79 |
)
|
| 80 |
appt = await get_appointment(aid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
return await _to_appt_response(appt)
|
|
|
|
|
|
|
| 82 |
except Exception as e:
|
| 83 |
-
logger.error(
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
@router.post("/list", response_model=None)
|
| 87 |
async def list_appointments_endpoint(
|
|
@@ -156,9 +182,40 @@ async def update_appointment_endpoint(
|
|
| 156 |
):
|
| 157 |
try:
|
| 158 |
appt = await update_appointment(appointment_id, req.start_time, [s.model_dump() for s in req.services], req.notes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
return await _to_appt_response(appt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
except Exception as e:
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
@router.patch("/{appointment_id}/status", response_model=AppointmentResponse)
|
| 164 |
async def update_status_endpoint(
|
|
@@ -168,9 +225,41 @@ async def update_status_endpoint(
|
|
| 168 |
):
|
| 169 |
try:
|
| 170 |
appt = await update_status(appointment_id, req.status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
return await _to_appt_response(appt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
except Exception as e:
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
@router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
|
| 176 |
async def cancel_appointment_endpoint(
|
|
@@ -179,9 +268,40 @@ async def cancel_appointment_endpoint(
|
|
| 179 |
):
|
| 180 |
try:
|
| 181 |
appt = await cancel_appointment(appointment_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
return await _to_appt_response(appt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
except Exception as e:
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
@router.post("/{appointment_id}/checkout", response_model=CheckoutResponse)
|
| 187 |
async def checkout_appointment_endpoint(
|
|
@@ -190,9 +310,41 @@ async def checkout_appointment_endpoint(
|
|
| 190 |
):
|
| 191 |
try:
|
| 192 |
sale_id, sale = await checkout_appointment(appointment_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
return CheckoutResponse(appointment_id=appointment_id, sale_id=sale_id, status="billed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
except Exception as e:
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
async def _to_appt_response(appt: dict) -> AppointmentResponse:
|
| 198 |
services = [AppointmentServiceResponse(
|
|
|
|
| 9 |
from fastapi import APIRouter, HTTPException, status, Depends
|
| 10 |
from fastapi.responses import JSONResponse
|
| 11 |
|
| 12 |
+
from app.core.logging import get_logger
|
| 13 |
from app.dependencies.auth import TokenUser
|
| 14 |
from app.dependencies.pos_permissions import require_pos_permission
|
| 15 |
from app.appointments.schemas.schema import (
|
|
|
|
| 32 |
checkout_appointment,
|
| 33 |
)
|
| 34 |
|
| 35 |
+
logger = get_logger(__name__)
|
| 36 |
|
| 37 |
def convert_uuids_to_strings(data):
|
| 38 |
"""Convert UUID objects to strings for JSON serialization"""
|
|
|
|
| 79 |
created_by=req.created_by,
|
| 80 |
)
|
| 81 |
appt = await get_appointment(aid)
|
| 82 |
+
|
| 83 |
+
logger.info(
|
| 84 |
+
"Appointment created successfully",
|
| 85 |
+
extra={
|
| 86 |
+
"operation": "create_appointment",
|
| 87 |
+
"appointment_id": str(aid),
|
| 88 |
+
"merchant_id": str(merchant_id),
|
| 89 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
return await _to_appt_response(appt)
|
| 94 |
+
except HTTPException:
|
| 95 |
+
raise
|
| 96 |
except Exception as e:
|
| 97 |
+
logger.error(
|
| 98 |
+
"Create appointment failed",
|
| 99 |
+
extra={
|
| 100 |
+
"operation": "create_appointment",
|
| 101 |
+
"error": str(e),
|
| 102 |
+
"error_type": type(e).__name__,
|
| 103 |
+
"merchant_id": str(merchant_id) if 'merchant_id' in locals() else None
|
| 104 |
+
},
|
| 105 |
+
exc_info=True
|
| 106 |
+
)
|
| 107 |
+
raise HTTPException(
|
| 108 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 109 |
+
detail="Failed to create appointment"
|
| 110 |
+
)
|
| 111 |
|
| 112 |
@router.post("/list", response_model=None)
|
| 113 |
async def list_appointments_endpoint(
|
|
|
|
| 182 |
):
|
| 183 |
try:
|
| 184 |
appt = await update_appointment(appointment_id, req.start_time, [s.model_dump() for s in req.services], req.notes)
|
| 185 |
+
|
| 186 |
+
logger.info(
|
| 187 |
+
"Appointment updated",
|
| 188 |
+
extra={
|
| 189 |
+
"operation": "update_appointment",
|
| 190 |
+
"appointment_id": str(appointment_id),
|
| 191 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 192 |
+
}
|
| 193 |
+
)
|
| 194 |
return await _to_appt_response(appt)
|
| 195 |
+
except HTTPException:
|
| 196 |
+
raise
|
| 197 |
+
except ValueError as e:
|
| 198 |
+
logger.warning(
|
| 199 |
+
"Update appointment validation failed",
|
| 200 |
+
extra={
|
| 201 |
+
"operation": "update_appointment",
|
| 202 |
+
"appointment_id": str(appointment_id),
|
| 203 |
+
"error": str(e)
|
| 204 |
+
}
|
| 205 |
+
)
|
| 206 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 207 |
except Exception as e:
|
| 208 |
+
logger.error(
|
| 209 |
+
"Update appointment failed",
|
| 210 |
+
extra={
|
| 211 |
+
"operation": "update_appointment",
|
| 212 |
+
"appointment_id": str(appointment_id),
|
| 213 |
+
"error": str(e),
|
| 214 |
+
"error_type": type(e).__name__
|
| 215 |
+
},
|
| 216 |
+
exc_info=True
|
| 217 |
+
)
|
| 218 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update appointment")
|
| 219 |
|
| 220 |
@router.patch("/{appointment_id}/status", response_model=AppointmentResponse)
|
| 221 |
async def update_status_endpoint(
|
|
|
|
| 225 |
):
|
| 226 |
try:
|
| 227 |
appt = await update_status(appointment_id, req.status)
|
| 228 |
+
|
| 229 |
+
logger.info(
|
| 230 |
+
"Appointment status updated",
|
| 231 |
+
extra={
|
| 232 |
+
"operation": "update_status",
|
| 233 |
+
"appointment_id": str(appointment_id),
|
| 234 |
+
"status": req.status,
|
| 235 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 236 |
+
}
|
| 237 |
+
)
|
| 238 |
return await _to_appt_response(appt)
|
| 239 |
+
except HTTPException:
|
| 240 |
+
raise
|
| 241 |
+
except ValueError as e:
|
| 242 |
+
logger.warning(
|
| 243 |
+
"Update status validation failed",
|
| 244 |
+
extra={
|
| 245 |
+
"operation": "update_status",
|
| 246 |
+
"appointment_id": str(appointment_id),
|
| 247 |
+
"error": str(e)
|
| 248 |
+
}
|
| 249 |
+
)
|
| 250 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 251 |
except Exception as e:
|
| 252 |
+
logger.error(
|
| 253 |
+
"Update status failed",
|
| 254 |
+
extra={
|
| 255 |
+
"operation": "update_status",
|
| 256 |
+
"appointment_id": str(appointment_id),
|
| 257 |
+
"error": str(e),
|
| 258 |
+
"error_type": type(e).__name__
|
| 259 |
+
},
|
| 260 |
+
exc_info=True
|
| 261 |
+
)
|
| 262 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update status")
|
| 263 |
|
| 264 |
@router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
|
| 265 |
async def cancel_appointment_endpoint(
|
|
|
|
| 268 |
):
|
| 269 |
try:
|
| 270 |
appt = await cancel_appointment(appointment_id)
|
| 271 |
+
|
| 272 |
+
logger.info(
|
| 273 |
+
"Appointment cancelled",
|
| 274 |
+
extra={
|
| 275 |
+
"operation": "cancel_appointment",
|
| 276 |
+
"appointment_id": str(appointment_id),
|
| 277 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 278 |
+
}
|
| 279 |
+
)
|
| 280 |
return await _to_appt_response(appt)
|
| 281 |
+
except HTTPException:
|
| 282 |
+
raise
|
| 283 |
+
except ValueError as e:
|
| 284 |
+
logger.warning(
|
| 285 |
+
"Cancel appointment validation failed",
|
| 286 |
+
extra={
|
| 287 |
+
"operation": "cancel_appointment",
|
| 288 |
+
"appointment_id": str(appointment_id),
|
| 289 |
+
"error": str(e)
|
| 290 |
+
}
|
| 291 |
+
)
|
| 292 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 293 |
except Exception as e:
|
| 294 |
+
logger.error(
|
| 295 |
+
"Cancel appointment failed",
|
| 296 |
+
extra={
|
| 297 |
+
"operation": "cancel_appointment",
|
| 298 |
+
"appointment_id": str(appointment_id),
|
| 299 |
+
"error": str(e),
|
| 300 |
+
"error_type": type(e).__name__
|
| 301 |
+
},
|
| 302 |
+
exc_info=True
|
| 303 |
+
)
|
| 304 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to cancel appointment")
|
| 305 |
|
| 306 |
@router.post("/{appointment_id}/checkout", response_model=CheckoutResponse)
|
| 307 |
async def checkout_appointment_endpoint(
|
|
|
|
| 310 |
):
|
| 311 |
try:
|
| 312 |
sale_id, sale = await checkout_appointment(appointment_id)
|
| 313 |
+
|
| 314 |
+
logger.info(
|
| 315 |
+
"Appointment checkout successful",
|
| 316 |
+
extra={
|
| 317 |
+
"operation": "checkout_appointment",
|
| 318 |
+
"appointment_id": str(appointment_id),
|
| 319 |
+
"sale_id": str(sale_id),
|
| 320 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 321 |
+
}
|
| 322 |
+
)
|
| 323 |
return CheckoutResponse(appointment_id=appointment_id, sale_id=sale_id, status="billed")
|
| 324 |
+
except HTTPException:
|
| 325 |
+
raise
|
| 326 |
+
except ValueError as e:
|
| 327 |
+
logger.warning(
|
| 328 |
+
"Checkout validation failed",
|
| 329 |
+
extra={
|
| 330 |
+
"operation": "checkout_appointment",
|
| 331 |
+
"appointment_id": str(appointment_id),
|
| 332 |
+
"error": str(e)
|
| 333 |
+
}
|
| 334 |
+
)
|
| 335 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 336 |
except Exception as e:
|
| 337 |
+
logger.error(
|
| 338 |
+
"Checkout failed",
|
| 339 |
+
extra={
|
| 340 |
+
"operation": "checkout_appointment",
|
| 341 |
+
"appointment_id": str(appointment_id),
|
| 342 |
+
"error": str(e),
|
| 343 |
+
"error_type": type(e).__name__
|
| 344 |
+
},
|
| 345 |
+
exc_info=True
|
| 346 |
+
)
|
| 347 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to checkout appointment")
|
| 348 |
|
| 349 |
async def _to_appt_response(appt: dict) -> AppointmentResponse:
|
| 350 |
services = [AppointmentServiceResponse(
|
app/appointments/services/service.py
CHANGED
|
@@ -7,9 +7,10 @@ from typing import Optional, List, Tuple
|
|
| 7 |
from datetime import timedelta
|
| 8 |
from sqlalchemy import text
|
| 9 |
|
|
|
|
| 10 |
from app.sql import get_postgres_session
|
| 11 |
|
| 12 |
-
logger =
|
| 13 |
|
| 14 |
def _service_details_from_input(s: dict) -> Tuple[str, int, float]:
|
| 15 |
"""Derive service details from input without Mongo.
|
|
@@ -104,11 +105,24 @@ async def create_appointment(
|
|
| 104 |
"price": e["service_price"],
|
| 105 |
})
|
| 106 |
await session.commit()
|
| 107 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return appointment_id
|
| 109 |
except Exception as e:
|
| 110 |
await session.rollback()
|
| 111 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
raise
|
| 113 |
|
| 114 |
async def get_appointment(appointment_id: UUID) -> Optional[dict]:
|
|
@@ -247,7 +261,15 @@ async def update_appointment(appointment_id: UUID, start_time, services: List[di
|
|
| 247 |
await session.commit()
|
| 248 |
except Exception as e:
|
| 249 |
await session.rollback()
|
| 250 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
raise
|
| 252 |
appt = await get_appointment(appointment_id)
|
| 253 |
return appt
|
|
@@ -271,7 +293,16 @@ async def update_status(appointment_id: UUID, status: str) -> dict:
|
|
| 271 |
await session.commit()
|
| 272 |
except Exception as e:
|
| 273 |
await session.rollback()
|
| 274 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
raise
|
| 276 |
return await get_appointment(appointment_id)
|
| 277 |
|
|
|
|
| 7 |
from datetime import timedelta
|
| 8 |
from sqlalchemy import text
|
| 9 |
|
| 10 |
+
from app.core.logging import get_logger
|
| 11 |
from app.sql import get_postgres_session
|
| 12 |
|
| 13 |
+
logger = get_logger(__name__)
|
| 14 |
|
| 15 |
def _service_details_from_input(s: dict) -> Tuple[str, int, float]:
|
| 16 |
"""Derive service details from input without Mongo.
|
|
|
|
| 105 |
"price": e["service_price"],
|
| 106 |
})
|
| 107 |
await session.commit()
|
| 108 |
+
logger.info(
|
| 109 |
+
"Created appointment",
|
| 110 |
+
extra={
|
| 111 |
+
"appointment_id": str(appointment_id),
|
| 112 |
+
"service_count": len(enriched)
|
| 113 |
+
}
|
| 114 |
+
)
|
| 115 |
return appointment_id
|
| 116 |
except Exception as e:
|
| 117 |
await session.rollback()
|
| 118 |
+
logger.error(
|
| 119 |
+
"Failed to create appointment",
|
| 120 |
+
extra={
|
| 121 |
+
"error": str(e),
|
| 122 |
+
"error_type": type(e).__name__
|
| 123 |
+
},
|
| 124 |
+
exc_info=True
|
| 125 |
+
)
|
| 126 |
raise
|
| 127 |
|
| 128 |
async def get_appointment(appointment_id: UUID) -> Optional[dict]:
|
|
|
|
| 261 |
await session.commit()
|
| 262 |
except Exception as e:
|
| 263 |
await session.rollback()
|
| 264 |
+
logger.error(
|
| 265 |
+
"Failed to update appointment",
|
| 266 |
+
extra={
|
| 267 |
+
"appointment_id": str(appointment_id),
|
| 268 |
+
"error": str(e),
|
| 269 |
+
"error_type": type(e).__name__
|
| 270 |
+
},
|
| 271 |
+
exc_info=True
|
| 272 |
+
)
|
| 273 |
raise
|
| 274 |
appt = await get_appointment(appointment_id)
|
| 275 |
return appt
|
|
|
|
| 293 |
await session.commit()
|
| 294 |
except Exception as e:
|
| 295 |
await session.rollback()
|
| 296 |
+
logger.error(
|
| 297 |
+
"Failed to update status",
|
| 298 |
+
extra={
|
| 299 |
+
"appointment_id": str(appointment_id),
|
| 300 |
+
"status": status,
|
| 301 |
+
"error": str(e),
|
| 302 |
+
"error_type": type(e).__name__
|
| 303 |
+
},
|
| 304 |
+
exc_info=True
|
| 305 |
+
)
|
| 306 |
raise
|
| 307 |
return await get_appointment(appointment_id)
|
| 308 |
|
app/catalogue_services/controllers/router.py
CHANGED
|
@@ -5,6 +5,7 @@ import logging
|
|
| 5 |
from typing import Optional
|
| 6 |
from fastapi import APIRouter, HTTPException, Query, status, Depends
|
| 7 |
|
|
|
|
| 8 |
from app.dependencies.auth import TokenUser
|
| 9 |
from app.dependencies.pos_permissions import require_pos_permission
|
| 10 |
from app.catalogue_services.schemas.schema import (
|
|
@@ -24,7 +25,7 @@ from app.catalogue_services.services.service import (
|
|
| 24 |
delete_service,
|
| 25 |
)
|
| 26 |
|
| 27 |
-
logger =
|
| 28 |
|
| 29 |
router = APIRouter(
|
| 30 |
prefix="/pos/catalogue/services",
|
|
@@ -56,10 +57,42 @@ async def create_service_endpoint(
|
|
| 56 |
price=req.price,
|
| 57 |
gst_rate=req.gst_rate,
|
| 58 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return _to_response(doc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
except Exception as e:
|
| 61 |
-
logger.error(
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
@router.post("/list", response_model=ListServicesResponse)
|
| 65 |
async def list_services_endpoint(
|
|
@@ -84,23 +117,59 @@ async def list_services_endpoint(
|
|
| 84 |
projection_list=req.projection_list
|
| 85 |
)
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
return ListServicesResponse(
|
| 88 |
items=items, # Service layer returns raw dicts
|
| 89 |
total=total
|
| 90 |
)
|
|
|
|
|
|
|
| 91 |
except Exception as e:
|
| 92 |
-
logger.error(
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
@router.get("/{service_id}", response_model=ServiceResponse)
|
| 96 |
async def get_service_endpoint(
|
| 97 |
service_id: str, # Changed from UUID to str
|
| 98 |
current_user: TokenUser = Depends(require_pos_permission("retail_catalogue", "view"))
|
| 99 |
):
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
@router.put("/{service_id}", response_model=ServiceResponse)
|
| 106 |
async def update_service_endpoint(
|
|
@@ -120,9 +189,40 @@ async def update_service_endpoint(
|
|
| 120 |
price=req.price,
|
| 121 |
gst_rate=req.gst_rate,
|
| 122 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
return _to_response(doc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
except Exception as e:
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
@router.patch("/{service_id}/status", response_model=ServiceResponse)
|
| 128 |
async def update_status_endpoint(
|
|
@@ -132,9 +232,41 @@ async def update_status_endpoint(
|
|
| 132 |
):
|
| 133 |
try:
|
| 134 |
doc = await update_status(service_id, req.status)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
return _to_response(doc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
except Exception as e:
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
@router.delete("/{service_id}", response_model=ServiceResponse)
|
| 140 |
async def delete_service_endpoint(
|
|
@@ -143,9 +275,40 @@ async def delete_service_endpoint(
|
|
| 143 |
):
|
| 144 |
try:
|
| 145 |
doc = await delete_service(service_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return _to_response(doc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
except Exception as e:
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
def _to_response(doc: dict) -> ServiceResponse:
|
| 151 |
return ServiceResponse(
|
|
|
|
| 5 |
from typing import Optional
|
| 6 |
from fastapi import APIRouter, HTTPException, Query, status, Depends
|
| 7 |
|
| 8 |
+
from app.core.logging import get_logger
|
| 9 |
from app.dependencies.auth import TokenUser
|
| 10 |
from app.dependencies.pos_permissions import require_pos_permission
|
| 11 |
from app.catalogue_services.schemas.schema import (
|
|
|
|
| 25 |
delete_service,
|
| 26 |
)
|
| 27 |
|
| 28 |
+
logger = get_logger(__name__)
|
| 29 |
|
| 30 |
router = APIRouter(
|
| 31 |
prefix="/pos/catalogue/services",
|
|
|
|
| 57 |
price=req.price,
|
| 58 |
gst_rate=req.gst_rate,
|
| 59 |
)
|
| 60 |
+
|
| 61 |
+
logger.info(
|
| 62 |
+
"Service created successfully",
|
| 63 |
+
extra={
|
| 64 |
+
"operation": "create_service",
|
| 65 |
+
"service_id": str(doc["_id"]),
|
| 66 |
+
"merchant_id": str(merchant_id),
|
| 67 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 68 |
+
}
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
return _to_response(doc)
|
| 72 |
+
except HTTPException:
|
| 73 |
+
raise
|
| 74 |
+
except ValueError as e:
|
| 75 |
+
logger.warning(
|
| 76 |
+
"Create service validation failed",
|
| 77 |
+
extra={
|
| 78 |
+
"operation": "create_service",
|
| 79 |
+
"error": str(e),
|
| 80 |
+
"merchant_id": str(merchant_id) if 'merchant_id' in locals() else None
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 84 |
except Exception as e:
|
| 85 |
+
logger.error(
|
| 86 |
+
"Create service failed",
|
| 87 |
+
extra={
|
| 88 |
+
"operation": "create_service",
|
| 89 |
+
"error": str(e),
|
| 90 |
+
"error_type": type(e).__name__,
|
| 91 |
+
"merchant_id": str(merchant_id) if 'merchant_id' in locals() else None
|
| 92 |
+
},
|
| 93 |
+
exc_info=True
|
| 94 |
+
)
|
| 95 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create service")
|
| 96 |
|
| 97 |
@router.post("/list", response_model=ListServicesResponse)
|
| 98 |
async def list_services_endpoint(
|
|
|
|
| 117 |
projection_list=req.projection_list
|
| 118 |
)
|
| 119 |
|
| 120 |
+
logger.info(
|
| 121 |
+
"Services listed",
|
| 122 |
+
extra={
|
| 123 |
+
"operation": "list_services",
|
| 124 |
+
"count": total,
|
| 125 |
+
"merchant_id": str(merchant_id),
|
| 126 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 127 |
+
}
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
return ListServicesResponse(
|
| 131 |
items=items, # Service layer returns raw dicts
|
| 132 |
total=total
|
| 133 |
)
|
| 134 |
+
except HTTPException:
|
| 135 |
+
raise
|
| 136 |
except Exception as e:
|
| 137 |
+
logger.error(
|
| 138 |
+
"List services failed",
|
| 139 |
+
extra={
|
| 140 |
+
"operation": "list_services",
|
| 141 |
+
"error": str(e),
|
| 142 |
+
"error_type": type(e).__name__,
|
| 143 |
+
"merchant_id": str(merchant_id) if 'merchant_id' in locals() else None
|
| 144 |
+
},
|
| 145 |
+
exc_info=True
|
| 146 |
+
)
|
| 147 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to list services")
|
| 148 |
|
| 149 |
@router.get("/{service_id}", response_model=ServiceResponse)
|
| 150 |
async def get_service_endpoint(
|
| 151 |
service_id: str, # Changed from UUID to str
|
| 152 |
current_user: TokenUser = Depends(require_pos_permission("retail_catalogue", "view"))
|
| 153 |
):
|
| 154 |
+
try:
|
| 155 |
+
doc = await get_service(service_id)
|
| 156 |
+
if not doc:
|
| 157 |
+
raise HTTPException(status_code=404, detail="Service not found")
|
| 158 |
+
return _to_response(doc)
|
| 159 |
+
except HTTPException:
|
| 160 |
+
raise
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.error(
|
| 163 |
+
"Get service failed",
|
| 164 |
+
extra={
|
| 165 |
+
"operation": "get_service",
|
| 166 |
+
"service_id": service_id,
|
| 167 |
+
"error": str(e),
|
| 168 |
+
"error_type": type(e).__name__
|
| 169 |
+
},
|
| 170 |
+
exc_info=True
|
| 171 |
+
)
|
| 172 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get service")
|
| 173 |
|
| 174 |
@router.put("/{service_id}", response_model=ServiceResponse)
|
| 175 |
async def update_service_endpoint(
|
|
|
|
| 189 |
price=req.price,
|
| 190 |
gst_rate=req.gst_rate,
|
| 191 |
)
|
| 192 |
+
|
| 193 |
+
logger.info(
|
| 194 |
+
"Service updated",
|
| 195 |
+
extra={
|
| 196 |
+
"operation": "update_service",
|
| 197 |
+
"service_id": service_id,
|
| 198 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 199 |
+
}
|
| 200 |
+
)
|
| 201 |
return _to_response(doc)
|
| 202 |
+
except HTTPException:
|
| 203 |
+
raise
|
| 204 |
+
except ValueError as e:
|
| 205 |
+
logger.warning(
|
| 206 |
+
"Update service validation failed",
|
| 207 |
+
extra={
|
| 208 |
+
"operation": "update_service",
|
| 209 |
+
"service_id": service_id,
|
| 210 |
+
"error": str(e)
|
| 211 |
+
}
|
| 212 |
+
)
|
| 213 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 214 |
except Exception as e:
|
| 215 |
+
logger.error(
|
| 216 |
+
"Update service failed",
|
| 217 |
+
extra={
|
| 218 |
+
"operation": "update_service",
|
| 219 |
+
"service_id": service_id,
|
| 220 |
+
"error": str(e),
|
| 221 |
+
"error_type": type(e).__name__
|
| 222 |
+
},
|
| 223 |
+
exc_info=True
|
| 224 |
+
)
|
| 225 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update service")
|
| 226 |
|
| 227 |
@router.patch("/{service_id}/status", response_model=ServiceResponse)
|
| 228 |
async def update_status_endpoint(
|
|
|
|
| 232 |
):
|
| 233 |
try:
|
| 234 |
doc = await update_status(service_id, req.status)
|
| 235 |
+
|
| 236 |
+
logger.info(
|
| 237 |
+
"Service status updated",
|
| 238 |
+
extra={
|
| 239 |
+
"operation": "update_status",
|
| 240 |
+
"service_id": service_id,
|
| 241 |
+
"status": req.status,
|
| 242 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 243 |
+
}
|
| 244 |
+
)
|
| 245 |
return _to_response(doc)
|
| 246 |
+
except HTTPException:
|
| 247 |
+
raise
|
| 248 |
+
except ValueError as e:
|
| 249 |
+
logger.warning(
|
| 250 |
+
"Update status validation failed",
|
| 251 |
+
extra={
|
| 252 |
+
"operation": "update_status",
|
| 253 |
+
"service_id": service_id,
|
| 254 |
+
"error": str(e)
|
| 255 |
+
}
|
| 256 |
+
)
|
| 257 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 258 |
except Exception as e:
|
| 259 |
+
logger.error(
|
| 260 |
+
"Update status failed",
|
| 261 |
+
extra={
|
| 262 |
+
"operation": "update_status",
|
| 263 |
+
"service_id": service_id,
|
| 264 |
+
"error": str(e),
|
| 265 |
+
"error_type": type(e).__name__
|
| 266 |
+
},
|
| 267 |
+
exc_info=True
|
| 268 |
+
)
|
| 269 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update status")
|
| 270 |
|
| 271 |
@router.delete("/{service_id}", response_model=ServiceResponse)
|
| 272 |
async def delete_service_endpoint(
|
|
|
|
| 275 |
):
|
| 276 |
try:
|
| 277 |
doc = await delete_service(service_id)
|
| 278 |
+
|
| 279 |
+
logger.info(
|
| 280 |
+
"Service deleted",
|
| 281 |
+
extra={
|
| 282 |
+
"operation": "delete_service",
|
| 283 |
+
"service_id": service_id,
|
| 284 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 285 |
+
}
|
| 286 |
+
)
|
| 287 |
return _to_response(doc)
|
| 288 |
+
except HTTPException:
|
| 289 |
+
raise
|
| 290 |
+
except ValueError as e:
|
| 291 |
+
logger.warning(
|
| 292 |
+
"Delete service validation failed",
|
| 293 |
+
extra={
|
| 294 |
+
"operation": "delete_service",
|
| 295 |
+
"service_id": service_id,
|
| 296 |
+
"error": str(e)
|
| 297 |
+
}
|
| 298 |
+
)
|
| 299 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 300 |
except Exception as e:
|
| 301 |
+
logger.error(
|
| 302 |
+
"Delete service failed",
|
| 303 |
+
extra={
|
| 304 |
+
"operation": "delete_service",
|
| 305 |
+
"service_id": service_id,
|
| 306 |
+
"error": str(e),
|
| 307 |
+
"error_type": type(e).__name__
|
| 308 |
+
},
|
| 309 |
+
exc_info=True
|
| 310 |
+
)
|
| 311 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete service")
|
| 312 |
|
| 313 |
def _to_response(doc: dict) -> ServiceResponse:
|
| 314 |
return ServiceResponse(
|
app/catalogue_services/services/service.py
CHANGED
|
@@ -7,10 +7,11 @@ from typing import Optional, List, Tuple
|
|
| 7 |
from datetime import datetime
|
| 8 |
from sqlalchemy import text
|
| 9 |
|
|
|
|
| 10 |
from app.nosql import get_database
|
| 11 |
from app.sql import get_postgres_session
|
| 12 |
|
| 13 |
-
logger =
|
| 14 |
|
| 15 |
CATALOGUE_SERVICES_COLLECTION = "pos_catalogue_services"
|
| 16 |
|
|
@@ -52,9 +53,25 @@ async def _sync_to_postgres(service_doc: dict):
|
|
| 52 |
"status": service_doc.get("status", "active"),
|
| 53 |
})
|
| 54 |
await session.commit()
|
| 55 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
except Exception as e:
|
| 57 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
await session.rollback()
|
| 59 |
|
| 60 |
async def create_service(
|
|
@@ -104,7 +121,14 @@ async def create_service(
|
|
| 104 |
}
|
| 105 |
|
| 106 |
await db[CATALOGUE_SERVICES_COLLECTION].insert_one(doc)
|
| 107 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
# Sync to Postgres
|
| 110 |
await _sync_to_postgres(doc)
|
|
@@ -266,7 +290,13 @@ async def update_service(
|
|
| 266 |
|
| 267 |
# Get updated doc
|
| 268 |
updated = await db[CATALOGUE_SERVICES_COLLECTION].find_one({"_id": service_id})
|
| 269 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
# Sync to Postgres
|
| 272 |
await _sync_to_postgres(updated)
|
|
@@ -289,7 +319,14 @@ async def update_status(service_id: str, status: str) -> dict: # Changed from U
|
|
| 289 |
)
|
| 290 |
|
| 291 |
updated = await db[CATALOGUE_SERVICES_COLLECTION].find_one({"_id": service_id})
|
| 292 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
# Sync to Postgres
|
| 295 |
await _sync_to_postgres(updated)
|
|
|
|
| 7 |
from datetime import datetime
|
| 8 |
from sqlalchemy import text
|
| 9 |
|
| 10 |
+
from app.core.logging import get_logger
|
| 11 |
from app.nosql import get_database
|
| 12 |
from app.sql import get_postgres_session
|
| 13 |
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
|
| 16 |
CATALOGUE_SERVICES_COLLECTION = "pos_catalogue_services"
|
| 17 |
|
|
|
|
| 53 |
"status": service_doc.get("status", "active"),
|
| 54 |
})
|
| 55 |
await session.commit()
|
| 56 |
+
logger.info(
|
| 57 |
+
f"Synced service {service_doc['_id']} to Postgres",
|
| 58 |
+
extra={
|
| 59 |
+
"operation": "sync_to_postgres",
|
| 60 |
+
"service_id": service_doc["_id"],
|
| 61 |
+
"merchant_id": service_doc["merchant_id"]
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
except Exception as e:
|
| 65 |
+
logger.error(
|
| 66 |
+
f"Failed to sync service to Postgres: {e}",
|
| 67 |
+
extra={
|
| 68 |
+
"operation": "sync_to_postgres",
|
| 69 |
+
"service_id": service_doc["_id"],
|
| 70 |
+
"merchant_id": service_doc["merchant_id"],
|
| 71 |
+
"error": str(e)
|
| 72 |
+
},
|
| 73 |
+
exc_info=True
|
| 74 |
+
)
|
| 75 |
await session.rollback()
|
| 76 |
|
| 77 |
async def create_service(
|
|
|
|
| 121 |
}
|
| 122 |
|
| 123 |
await db[CATALOGUE_SERVICES_COLLECTION].insert_one(doc)
|
| 124 |
+
logger.info(
|
| 125 |
+
f"Created service {service_id} in MongoDB",
|
| 126 |
+
extra={
|
| 127 |
+
"operation": "create_service_mongo",
|
| 128 |
+
"service_id": service_id,
|
| 129 |
+
"merchant_id": str(merchant_id)
|
| 130 |
+
}
|
| 131 |
+
)
|
| 132 |
|
| 133 |
# Sync to Postgres
|
| 134 |
await _sync_to_postgres(doc)
|
|
|
|
| 290 |
|
| 291 |
# Get updated doc
|
| 292 |
updated = await db[CATALOGUE_SERVICES_COLLECTION].find_one({"_id": service_id})
|
| 293 |
+
logger.info(
|
| 294 |
+
f"Updated service {service_id} in MongoDB",
|
| 295 |
+
extra={
|
| 296 |
+
"operation": "update_service_mongo",
|
| 297 |
+
"service_id": service_id
|
| 298 |
+
}
|
| 299 |
+
)
|
| 300 |
|
| 301 |
# Sync to Postgres
|
| 302 |
await _sync_to_postgres(updated)
|
|
|
|
| 319 |
)
|
| 320 |
|
| 321 |
updated = await db[CATALOGUE_SERVICES_COLLECTION].find_one({"_id": service_id})
|
| 322 |
+
logger.info(
|
| 323 |
+
f"Updated service {service_id} status to {status}",
|
| 324 |
+
extra={
|
| 325 |
+
"operation": "update_status_mongo",
|
| 326 |
+
"service_id": service_id,
|
| 327 |
+
"status": status
|
| 328 |
+
}
|
| 329 |
+
)
|
| 330 |
|
| 331 |
# Sync to Postgres
|
| 332 |
await _sync_to_postgres(updated)
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
def get_logger(name: str) -> logging.Logger:
|
| 4 |
+
"""
|
| 5 |
+
Get a logger instance with the specified name.
|
| 6 |
+
|
| 7 |
+
Args:
|
| 8 |
+
name: The name of the logger, typically __name__
|
| 9 |
+
|
| 10 |
+
Returns:
|
| 11 |
+
logging.Logger: The configured logger instance
|
| 12 |
+
"""
|
| 13 |
+
return logging.getLogger(name)
|
app/customers/controllers/router.py
CHANGED
|
@@ -4,6 +4,7 @@ Customer API router for POS microservice.
|
|
| 4 |
from typing import Optional
|
| 5 |
from fastapi import APIRouter, HTTPException, Query, status, Depends
|
| 6 |
|
|
|
|
| 7 |
from app.dependencies.auth import TokenUser
|
| 8 |
from app.dependencies.pos_permissions import require_pos_permission
|
| 9 |
from app.customers.schemas.schema import (
|
|
@@ -15,6 +16,8 @@ from app.customers.schemas.schema import (
|
|
| 15 |
)
|
| 16 |
from app.customers.services.service import CustomerService
|
| 17 |
|
|
|
|
|
|
|
| 18 |
router = APIRouter(
|
| 19 |
prefix="/customers",
|
| 20 |
tags=["Customers"],
|
|
@@ -33,10 +36,46 @@ async def create_customer(
|
|
| 33 |
**Note:** merchant_id from request payload is ignored. The merchant_id from the authentication token is used instead.
|
| 34 |
This ensures proper merchant isolation and UUID format consistency.
|
| 35 |
"""
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
@router.get("/{customer_id}", response_model=CustomerResponse, summary="Get customer by ID")
|
|
@@ -44,14 +83,29 @@ async def get_customer(
|
|
| 44 |
customer_id: str,
|
| 45 |
current_user: TokenUser = Depends(require_pos_permission("customers", "view"))
|
| 46 |
) -> CustomerResponse:
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
@router.post("/list", response_model=None, summary="List customers")
|
|
@@ -78,40 +132,65 @@ async def list_customers(
|
|
| 78 |
- skip: Records to skip (default: 0)
|
| 79 |
- limit: Max records to return (default: 50, max: 500)
|
| 80 |
"""
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
limit=limit,
|
| 97 |
-
projection_list=projection_list
|
| 98 |
-
)
|
| 99 |
-
|
| 100 |
-
# Return raw dict if projection used, model otherwise
|
| 101 |
-
if projection_list:
|
| 102 |
-
return CustomerListResponse(
|
| 103 |
-
customers=customers, # Raw dicts when projection is used
|
| 104 |
-
total=total,
|
| 105 |
skip=skip,
|
| 106 |
-
limit=limit
|
|
|
|
| 107 |
)
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
)
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
@router.put("/{customer_id}", response_model=CustomerResponse, summary="Update customer")
|
|
@@ -120,12 +199,47 @@ async def update_customer(
|
|
| 120 |
payload: CustomerUpdate,
|
| 121 |
current_user: TokenUser = Depends(require_pos_permission("customers", "update"))
|
| 122 |
) -> CustomerResponse:
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete customer (soft)")
|
|
@@ -133,9 +247,33 @@ async def delete_customer(
|
|
| 133 |
customer_id: str,
|
| 134 |
current_user: TokenUser = Depends(require_pos_permission("customers", "delete"))
|
| 135 |
):
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from typing import Optional
|
| 5 |
from fastapi import APIRouter, HTTPException, Query, status, Depends
|
| 6 |
|
| 7 |
+
from app.core.logging import get_logger
|
| 8 |
from app.dependencies.auth import TokenUser
|
| 9 |
from app.dependencies.pos_permissions import require_pos_permission
|
| 10 |
from app.customers.schemas.schema import (
|
|
|
|
| 16 |
)
|
| 17 |
from app.customers.services.service import CustomerService
|
| 18 |
|
| 19 |
+
logger = get_logger(__name__)
|
| 20 |
+
|
| 21 |
router = APIRouter(
|
| 22 |
prefix="/customers",
|
| 23 |
tags=["Customers"],
|
|
|
|
| 36 |
**Note:** merchant_id from request payload is ignored. The merchant_id from the authentication token is used instead.
|
| 37 |
This ensures proper merchant isolation and UUID format consistency.
|
| 38 |
"""
|
| 39 |
+
try:
|
| 40 |
+
if not current_user.merchant_id:
|
| 41 |
+
raise HTTPException(status_code=400, detail="merchant_id must be available in token")
|
| 42 |
+
|
| 43 |
+
customer = await CustomerService.create_customer(payload, current_user.merchant_id)
|
| 44 |
+
|
| 45 |
+
logger.info(
|
| 46 |
+
"Customer created successfully",
|
| 47 |
+
extra={
|
| 48 |
+
"operation": "create_customer",
|
| 49 |
+
"customer_id": customer.customer_id,
|
| 50 |
+
"merchant_id": str(current_user.merchant_id),
|
| 51 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
return customer
|
| 55 |
+
except HTTPException:
|
| 56 |
+
raise
|
| 57 |
+
except ValueError as e:
|
| 58 |
+
logger.warning(
|
| 59 |
+
"Create customer validation failed",
|
| 60 |
+
extra={
|
| 61 |
+
"operation": "create_customer",
|
| 62 |
+
"error": str(e),
|
| 63 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(
|
| 69 |
+
"Create customer failed",
|
| 70 |
+
extra={
|
| 71 |
+
"operation": "create_customer",
|
| 72 |
+
"error": str(e),
|
| 73 |
+
"error_type": type(e).__name__,
|
| 74 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 75 |
+
},
|
| 76 |
+
exc_info=True
|
| 77 |
+
)
|
| 78 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create customer")
|
| 79 |
|
| 80 |
|
| 81 |
@router.get("/{customer_id}", response_model=CustomerResponse, summary="Get customer by ID")
|
|
|
|
| 83 |
customer_id: str,
|
| 84 |
current_user: TokenUser = Depends(require_pos_permission("customers", "view"))
|
| 85 |
) -> CustomerResponse:
|
| 86 |
+
try:
|
| 87 |
+
customer = await CustomerService.get_customer(
|
| 88 |
+
customer_id=customer_id,
|
| 89 |
+
merchant_id=current_user.merchant_id,
|
| 90 |
+
merchant_type=current_user.merchant_type
|
| 91 |
+
)
|
| 92 |
+
if not customer:
|
| 93 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
|
| 94 |
+
return customer
|
| 95 |
+
except HTTPException:
|
| 96 |
+
raise
|
| 97 |
+
except Exception as e:
|
| 98 |
+
logger.error(
|
| 99 |
+
"Get customer failed",
|
| 100 |
+
extra={
|
| 101 |
+
"operation": "get_customer",
|
| 102 |
+
"customer_id": customer_id,
|
| 103 |
+
"error": str(e),
|
| 104 |
+
"error_type": type(e).__name__
|
| 105 |
+
},
|
| 106 |
+
exc_info=True
|
| 107 |
+
)
|
| 108 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get customer")
|
| 109 |
|
| 110 |
|
| 111 |
@router.post("/list", response_model=None, summary="List customers")
|
|
|
|
| 132 |
- skip: Records to skip (default: 0)
|
| 133 |
- limit: Max records to return (default: 50, max: 500)
|
| 134 |
"""
|
| 135 |
+
try:
|
| 136 |
+
# Extract filters and parameters from payload
|
| 137 |
+
filters = payload.filters or {}
|
| 138 |
+
skip = payload.skip or 0
|
| 139 |
+
limit = payload.limit or 50
|
| 140 |
+
projection_list = payload.projection_list
|
| 141 |
+
|
| 142 |
+
# Always use merchant_id from token
|
| 143 |
+
if not current_user.merchant_id:
|
| 144 |
+
raise HTTPException(status_code=400, detail="merchant_id must be available in token")
|
| 145 |
+
|
| 146 |
+
customers, total = await CustomerService.list_customers(
|
| 147 |
+
merchant_id=current_user.merchant_id,
|
| 148 |
+
merchant_type=current_user.merchant_type,
|
| 149 |
+
filters=filters,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
skip=skip,
|
| 151 |
+
limit=limit,
|
| 152 |
+
projection_list=projection_list
|
| 153 |
)
|
| 154 |
+
|
| 155 |
+
logger.info(
|
| 156 |
+
"Customers listed",
|
| 157 |
+
extra={
|
| 158 |
+
"operation": "list_customers",
|
| 159 |
+
"count": total,
|
| 160 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None,
|
| 161 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Return raw dict if projection used, model otherwise
|
| 166 |
+
if projection_list:
|
| 167 |
+
return CustomerListResponse(
|
| 168 |
+
customers=customers, # Raw dicts when projection is used
|
| 169 |
+
total=total,
|
| 170 |
+
skip=skip,
|
| 171 |
+
limit=limit
|
| 172 |
+
)
|
| 173 |
+
else:
|
| 174 |
+
return CustomerListResponse(
|
| 175 |
+
customers=customers, # CustomerResponse objects when no projection
|
| 176 |
+
total=total,
|
| 177 |
+
skip=skip,
|
| 178 |
+
limit=limit
|
| 179 |
+
)
|
| 180 |
+
except HTTPException:
|
| 181 |
+
raise
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(
|
| 184 |
+
"List customers failed",
|
| 185 |
+
extra={
|
| 186 |
+
"operation": "list_customers",
|
| 187 |
+
"error": str(e),
|
| 188 |
+
"error_type": type(e).__name__,
|
| 189 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 190 |
+
},
|
| 191 |
+
exc_info=True
|
| 192 |
)
|
| 193 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to list customers")
|
| 194 |
|
| 195 |
|
| 196 |
@router.put("/{customer_id}", response_model=CustomerResponse, summary="Update customer")
|
|
|
|
| 199 |
payload: CustomerUpdate,
|
| 200 |
current_user: TokenUser = Depends(require_pos_permission("customers", "update"))
|
| 201 |
) -> CustomerResponse:
|
| 202 |
+
try:
|
| 203 |
+
customer = await CustomerService.update_customer(
|
| 204 |
+
customer_id=customer_id,
|
| 205 |
+
payload=payload,
|
| 206 |
+
merchant_id=current_user.merchant_id,
|
| 207 |
+
merchant_type=current_user.merchant_type
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
logger.info(
|
| 211 |
+
"Customer updated",
|
| 212 |
+
extra={
|
| 213 |
+
"operation": "update_customer",
|
| 214 |
+
"customer_id": customer_id,
|
| 215 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 216 |
+
}
|
| 217 |
+
)
|
| 218 |
+
return customer
|
| 219 |
+
except HTTPException:
|
| 220 |
+
raise
|
| 221 |
+
except ValueError as e:
|
| 222 |
+
logger.warning(
|
| 223 |
+
"Update customer validation failed",
|
| 224 |
+
extra={
|
| 225 |
+
"operation": "update_customer",
|
| 226 |
+
"customer_id": customer_id,
|
| 227 |
+
"error": str(e)
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 231 |
+
except Exception as e:
|
| 232 |
+
logger.error(
|
| 233 |
+
"Update customer failed",
|
| 234 |
+
extra={
|
| 235 |
+
"operation": "update_customer",
|
| 236 |
+
"customer_id": customer_id,
|
| 237 |
+
"error": str(e),
|
| 238 |
+
"error_type": type(e).__name__
|
| 239 |
+
},
|
| 240 |
+
exc_info=True
|
| 241 |
+
)
|
| 242 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update customer")
|
| 243 |
|
| 244 |
|
| 245 |
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete customer (soft)")
|
|
|
|
| 247 |
customer_id: str,
|
| 248 |
current_user: TokenUser = Depends(require_pos_permission("customers", "delete"))
|
| 249 |
):
|
| 250 |
+
try:
|
| 251 |
+
await CustomerService.delete_customer(
|
| 252 |
+
customer_id=customer_id,
|
| 253 |
+
merchant_id=current_user.merchant_id,
|
| 254 |
+
merchant_type=current_user.merchant_type
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
logger.info(
|
| 258 |
+
"Customer deleted",
|
| 259 |
+
extra={
|
| 260 |
+
"operation": "delete_customer",
|
| 261 |
+
"customer_id": customer_id,
|
| 262 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 263 |
+
}
|
| 264 |
+
)
|
| 265 |
+
return None
|
| 266 |
+
except HTTPException:
|
| 267 |
+
raise
|
| 268 |
+
except Exception as e:
|
| 269 |
+
logger.error(
|
| 270 |
+
"Delete customer failed",
|
| 271 |
+
extra={
|
| 272 |
+
"operation": "delete_customer",
|
| 273 |
+
"customer_id": customer_id,
|
| 274 |
+
"error": str(e),
|
| 275 |
+
"error_type": type(e).__name__
|
| 276 |
+
},
|
| 277 |
+
exc_info=True
|
| 278 |
+
)
|
| 279 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete customer")
|
app/customers/services/service.py
CHANGED
|
@@ -8,6 +8,7 @@ from typing import Optional, List, Tuple
|
|
| 8 |
from fastapi import HTTPException, status
|
| 9 |
from sqlalchemy import text
|
| 10 |
|
|
|
|
| 11 |
from app.nosql import get_database
|
| 12 |
from app.constants.collections import POS_CUSTOMERS_COLLECTION
|
| 13 |
from app.sql import get_postgres_session
|
|
@@ -19,7 +20,7 @@ from app.customers.schemas.schema import (
|
|
| 19 |
CustomerListResponse,
|
| 20 |
)
|
| 21 |
|
| 22 |
-
logger =
|
| 23 |
|
| 24 |
|
| 25 |
class CustomerService:
|
|
@@ -38,7 +39,13 @@ class CustomerService:
|
|
| 38 |
"""Upsert customer into trans.pos_customer_ref using token merchant_id for UUID compatibility."""
|
| 39 |
async with get_postgres_session() as session:
|
| 40 |
if session is None:
|
| 41 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
return
|
| 43 |
|
| 44 |
# Use token merchant_id if provided (UUID format), otherwise use customer merchant_id
|
|
@@ -73,7 +80,15 @@ class CustomerService:
|
|
| 73 |
"updated_at": customer.updated_at,
|
| 74 |
})
|
| 75 |
await session.commit()
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
@classmethod
|
| 79 |
async def create_customer(cls, payload: CustomerCreate, token_merchant_id: Optional[str] = None) -> CustomerResponse:
|
|
@@ -95,7 +110,16 @@ class CustomerService:
|
|
| 95 |
try:
|
| 96 |
await cls._sync_to_postgres(customer, token_merchant_id)
|
| 97 |
except Exception as e:
|
| 98 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
return customer
|
| 101 |
|
|
@@ -203,7 +227,16 @@ class CustomerService:
|
|
| 203 |
try:
|
| 204 |
await cls._sync_to_postgres(customer, merchant_id)
|
| 205 |
except Exception as e:
|
| 206 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
return customer
|
| 209 |
|
|
@@ -237,6 +270,15 @@ class CustomerService:
|
|
| 237 |
try:
|
| 238 |
await cls._sync_to_postgres(customer, merchant_id)
|
| 239 |
except Exception as e:
|
| 240 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
return None
|
|
|
|
| 8 |
from fastapi import HTTPException, status
|
| 9 |
from sqlalchemy import text
|
| 10 |
|
| 11 |
+
from app.core.logging import get_logger
|
| 12 |
from app.nosql import get_database
|
| 13 |
from app.constants.collections import POS_CUSTOMERS_COLLECTION
|
| 14 |
from app.sql import get_postgres_session
|
|
|
|
| 20 |
CustomerListResponse,
|
| 21 |
)
|
| 22 |
|
| 23 |
+
logger = get_logger(__name__)
|
| 24 |
|
| 25 |
|
| 26 |
class CustomerService:
|
|
|
|
| 39 |
"""Upsert customer into trans.pos_customer_ref using token merchant_id for UUID compatibility."""
|
| 40 |
async with get_postgres_session() as session:
|
| 41 |
if session is None:
|
| 42 |
+
logger.warning(
|
| 43 |
+
"PostgreSQL session unavailable; skipping customer sync",
|
| 44 |
+
extra={
|
| 45 |
+
"operation": "sync_to_postgres",
|
| 46 |
+
"customer_id": customer.customer_id
|
| 47 |
+
}
|
| 48 |
+
)
|
| 49 |
return
|
| 50 |
|
| 51 |
# Use token merchant_id if provided (UUID format), otherwise use customer merchant_id
|
|
|
|
| 80 |
"updated_at": customer.updated_at,
|
| 81 |
})
|
| 82 |
await session.commit()
|
| 83 |
+
|
| 84 |
+
logger.info(
|
| 85 |
+
f"Synced customer {customer.customer_id} to Postgres",
|
| 86 |
+
extra={
|
| 87 |
+
"operation": "sync_to_postgres",
|
| 88 |
+
"customer_id": customer.customer_id,
|
| 89 |
+
"merchant_id": sync_merchant_id
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
|
| 93 |
@classmethod
|
| 94 |
async def create_customer(cls, payload: CustomerCreate, token_merchant_id: Optional[str] = None) -> CustomerResponse:
|
|
|
|
| 110 |
try:
|
| 111 |
await cls._sync_to_postgres(customer, token_merchant_id)
|
| 112 |
except Exception as e:
|
| 113 |
+
logger.error(
|
| 114 |
+
f"Postgres sync failed for customer {customer_id}",
|
| 115 |
+
extra={
|
| 116 |
+
"operation": "create_customer_sync",
|
| 117 |
+
"customer_id": customer_id,
|
| 118 |
+
"error": str(e),
|
| 119 |
+
"merchant_id": token_merchant_id
|
| 120 |
+
},
|
| 121 |
+
exc_info=True
|
| 122 |
+
)
|
| 123 |
|
| 124 |
return customer
|
| 125 |
|
|
|
|
| 227 |
try:
|
| 228 |
await cls._sync_to_postgres(customer, merchant_id)
|
| 229 |
except Exception as e:
|
| 230 |
+
logger.error(
|
| 231 |
+
f"Postgres sync failed for customer {customer_id}",
|
| 232 |
+
extra={
|
| 233 |
+
"operation": "update_customer_sync",
|
| 234 |
+
"customer_id": customer_id,
|
| 235 |
+
"error": str(e),
|
| 236 |
+
"merchant_id": merchant_id
|
| 237 |
+
},
|
| 238 |
+
exc_info=True
|
| 239 |
+
)
|
| 240 |
|
| 241 |
return customer
|
| 242 |
|
|
|
|
| 270 |
try:
|
| 271 |
await cls._sync_to_postgres(customer, merchant_id)
|
| 272 |
except Exception as e:
|
| 273 |
+
logger.error(
|
| 274 |
+
f"Postgres sync failed for customer {customer_id}",
|
| 275 |
+
extra={
|
| 276 |
+
"operation": "delete_customer_sync",
|
| 277 |
+
"customer_id": customer_id,
|
| 278 |
+
"error": str(e),
|
| 279 |
+
"merchant_id": merchant_id
|
| 280 |
+
},
|
| 281 |
+
exc_info=True
|
| 282 |
+
)
|
| 283 |
|
| 284 |
return None
|
app/main.py
CHANGED
|
@@ -7,10 +7,11 @@ from fastapi.exceptions import RequestValidationError
|
|
| 7 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 8 |
from fastapi.responses import JSONResponse
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
-
|
| 11 |
-
from
|
| 12 |
|
| 13 |
from app.core.config import settings
|
|
|
|
| 14 |
from app.utils.response import error_response
|
| 15 |
from app.middleware.logging_middleware import RequestLoggingMiddleware
|
| 16 |
|
|
@@ -22,8 +23,7 @@ from app.customers.controllers.router import router as customers_router
|
|
| 22 |
from app.sales.retail.controllers.router import router as sales_router
|
| 23 |
from app.appointments.controllers.router import router as appointments_router
|
| 24 |
|
| 25 |
-
|
| 26 |
-
logger = logging.getLogger(__name__)
|
| 27 |
logging.basicConfig(level=logging.INFO)
|
| 28 |
|
| 29 |
# Create FastAPI app
|
|
@@ -52,16 +52,25 @@ app.add_middleware(
|
|
| 52 |
@app.exception_handler(RequestValidationError)
|
| 53 |
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 54 |
"""Handle validation errors"""
|
| 55 |
-
errors = [
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return JSONResponse(
|
| 66 |
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 67 |
content=error_response(
|
|
@@ -73,6 +82,28 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|
| 73 |
)
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
@app.exception_handler(status.HTTP_404_NOT_FOUND)
|
| 77 |
async def not_found_exception_handler(request: Request, exc: Exception):
|
| 78 |
"""Handle 404 errors"""
|
|
@@ -113,43 +144,56 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
|
| 113 |
)
|
| 114 |
|
| 115 |
|
| 116 |
-
@app.exception_handler(ConnectionFailure)
|
| 117 |
-
async def mongo_connection_exception_handler(request: Request, exc: ConnectionFailure):
|
| 118 |
-
"""Handle MongoDB connection errors"""
|
| 119 |
-
logger.error(f"Database connection error: {exc}", exc_info=True)
|
| 120 |
-
return JSONResponse(
|
| 121 |
-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 122 |
-
content=error_response(
|
| 123 |
-
error="Database Connection Error",
|
| 124 |
-
detail="Unable to connect to the database. Please try again later.",
|
| 125 |
-
request_id=getattr(request.state, "request_id", None)
|
| 126 |
-
)
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
@app.exception_handler(PyMongoError)
|
| 131 |
-
async def
|
| 132 |
-
"""Handle
|
| 133 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
return JSONResponse(
|
| 135 |
-
status_code=
|
| 136 |
content=error_response(
|
| 137 |
error="Database Error",
|
| 138 |
-
detail=
|
| 139 |
request_id=getattr(request.state, "request_id", None)
|
| 140 |
)
|
| 141 |
)
|
| 142 |
|
| 143 |
|
| 144 |
-
# Handle generic HTTPException (catch-all for other HTTP exceptions)
|
| 145 |
@app.exception_handler(Exception)
|
| 146 |
-
async def
|
| 147 |
"""
|
| 148 |
Handle all unhandled exceptions.
|
| 149 |
-
|
| 150 |
-
In production, we should not expose internal error details.
|
| 151 |
"""
|
| 152 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return JSONResponse(
|
| 154 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 155 |
content=error_response(
|
|
|
|
| 7 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 8 |
from fastapi.responses import JSONResponse
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from pymongo.errors import PyMongoError, ConnectionFailure, OperationFailure
|
| 11 |
+
from jose import JWTError
|
| 12 |
|
| 13 |
from app.core.config import settings
|
| 14 |
+
from app.core.logging import get_logger
|
| 15 |
from app.utils.response import error_response
|
| 16 |
from app.middleware.logging_middleware import RequestLoggingMiddleware
|
| 17 |
|
|
|
|
| 23 |
from app.sales.retail.controllers.router import router as sales_router
|
| 24 |
from app.appointments.controllers.router import router as appointments_router
|
| 25 |
|
| 26 |
+
logger = get_logger(__name__)
|
|
|
|
| 27 |
logging.basicConfig(level=logging.INFO)
|
| 28 |
|
| 29 |
# Create FastAPI app
|
|
|
|
| 52 |
@app.exception_handler(RequestValidationError)
|
| 53 |
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 54 |
"""Handle validation errors"""
|
| 55 |
+
errors = [
|
| 56 |
+
{
|
| 57 |
+
"field": " -> ".join(str(loc) for loc in error["loc"]),
|
| 58 |
+
"message": error["msg"],
|
| 59 |
+
"type": error["type"]
|
| 60 |
+
}
|
| 61 |
+
for error in exc.errors()
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
logger.warning(
|
| 65 |
+
"Validation error",
|
| 66 |
+
extra={
|
| 67 |
+
"path": request.url.path,
|
| 68 |
+
"method": request.method,
|
| 69 |
+
"error_count": len(errors),
|
| 70 |
+
"errors": errors
|
| 71 |
+
}
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
return JSONResponse(
|
| 75 |
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 76 |
content=error_response(
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
|
| 85 |
+
@app.exception_handler(JWTError)
|
| 86 |
+
async def jwt_exception_handler(request: Request, exc: JWTError):
|
| 87 |
+
"""Handle JWT errors"""
|
| 88 |
+
logger.warning(
|
| 89 |
+
"JWT authentication failed",
|
| 90 |
+
extra={
|
| 91 |
+
"path": request.url.path,
|
| 92 |
+
"error": str(exc),
|
| 93 |
+
"client_ip": request.client.host if request.client else None
|
| 94 |
+
}
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return JSONResponse(
|
| 98 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 99 |
+
content=error_response(
|
| 100 |
+
error="Unauthorized",
|
| 101 |
+
detail="Invalid or expired token",
|
| 102 |
+
request_id=getattr(request.state, "request_id", None)
|
| 103 |
+
)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
@app.exception_handler(status.HTTP_404_NOT_FOUND)
|
| 108 |
async def not_found_exception_handler(request: Request, exc: Exception):
|
| 109 |
"""Handle 404 errors"""
|
|
|
|
| 144 |
)
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
@app.exception_handler(PyMongoError)
|
| 148 |
+
async def mongodb_exception_handler(request: Request, exc: PyMongoError):
|
| 149 |
+
"""Handle MongoDB errors"""
|
| 150 |
+
logger.error(
|
| 151 |
+
"Database error",
|
| 152 |
+
extra={
|
| 153 |
+
"path": request.url.path,
|
| 154 |
+
"error": str(exc),
|
| 155 |
+
"error_type": type(exc).__name__
|
| 156 |
+
},
|
| 157 |
+
exc_info=True
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
if isinstance(exc, ConnectionFailure):
|
| 161 |
+
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
| 162 |
+
detail = "Database connection failed"
|
| 163 |
+
elif isinstance(exc, OperationFailure):
|
| 164 |
+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 165 |
+
detail = "Database operation failed"
|
| 166 |
+
else:
|
| 167 |
+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 168 |
+
detail = "Database error occurred"
|
| 169 |
+
|
| 170 |
return JSONResponse(
|
| 171 |
+
status_code=status_code,
|
| 172 |
content=error_response(
|
| 173 |
error="Database Error",
|
| 174 |
+
detail=detail,
|
| 175 |
request_id=getattr(request.state, "request_id", None)
|
| 176 |
)
|
| 177 |
)
|
| 178 |
|
| 179 |
|
|
|
|
| 180 |
@app.exception_handler(Exception)
|
| 181 |
+
async def general_exception_handler(request: Request, exc: Exception):
|
| 182 |
"""
|
| 183 |
Handle all unhandled exceptions.
|
|
|
|
|
|
|
| 184 |
"""
|
| 185 |
+
logger.error(
|
| 186 |
+
"Unhandled exception",
|
| 187 |
+
extra={
|
| 188 |
+
"method": request.method,
|
| 189 |
+
"path": request.url.path,
|
| 190 |
+
"error": str(exc),
|
| 191 |
+
"error_type": type(exc).__name__,
|
| 192 |
+
"client_ip": request.client.host if request.client else None
|
| 193 |
+
},
|
| 194 |
+
exc_info=True
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
return JSONResponse(
|
| 198 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 199 |
content=error_response(
|
app/sales/orders/controllers/router.py
CHANGED
|
@@ -3,8 +3,7 @@ Sales Order API router - FastAPI endpoints for sales order operations.
|
|
| 3 |
"""
|
| 4 |
from typing import Optional
|
| 5 |
from fastapi import APIRouter, HTTPException, Query, Header, status, Depends
|
| 6 |
-
|
| 7 |
-
import logging
|
| 8 |
|
| 9 |
from app.dependencies.auth import TokenUser
|
| 10 |
from app.dependencies.pos_permissions import require_pos_permission
|
|
@@ -20,8 +19,7 @@ from app.sales.orders.schemas.schema import (
|
|
| 20 |
from app.sales.orders.services.service import SalesOrderService
|
| 21 |
from app.utils.response import success_response, paginated_response
|
| 22 |
|
| 23 |
-
|
| 24 |
-
logger = logging.getLogger(__name__)
|
| 25 |
|
| 26 |
router = APIRouter(
|
| 27 |
prefix="/sales",
|
|
@@ -64,14 +62,32 @@ async def list_sales_orders(
|
|
| 64 |
- field: Field to sort by (default: order_date)
|
| 65 |
- order: Sort order (asc/desc, default: desc)
|
| 66 |
"""
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
@router.get(
|
|
@@ -96,7 +112,25 @@ async def get_sales_order(
|
|
| 96 |
- Shipment information
|
| 97 |
- Invoice details (if generated)
|
| 98 |
"""
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
@router.post(
|
|
@@ -141,16 +175,45 @@ async def create_sales_order(
|
|
| 141 |
- credit/cod → unpaid (amount_paid = 0)
|
| 142 |
- partial → partial (custom amount_paid)
|
| 143 |
"""
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
"
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
@router.put(
|
|
@@ -181,20 +244,50 @@ async def update_sales_order(
|
|
| 181 |
- internal_notes: Internal notes
|
| 182 |
- status: Order status
|
| 183 |
"""
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
"
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
@router.post(
|
|
@@ -223,12 +316,41 @@ async def generate_invoice(
|
|
| 223 |
- Invoice not already generated
|
| 224 |
- Order must be in confirmed or processing status (not enforced)
|
| 225 |
"""
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
|
| 234 |
@router.get(
|
|
@@ -252,4 +374,21 @@ async def get_sales_widgets(
|
|
| 252 |
|
| 253 |
All metrics are scoped to the merchant.
|
| 254 |
"""
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
from typing import Optional
|
| 5 |
from fastapi import APIRouter, HTTPException, Query, Header, status, Depends
|
| 6 |
+
from app.core.logging import get_logger
|
|
|
|
| 7 |
|
| 8 |
from app.dependencies.auth import TokenUser
|
| 9 |
from app.dependencies.pos_permissions import require_pos_permission
|
|
|
|
| 19 |
from app.sales.orders.services.service import SalesOrderService
|
| 20 |
from app.utils.response import success_response, paginated_response
|
| 21 |
|
| 22 |
+
logger = get_logger(__name__)
|
|
|
|
| 23 |
|
| 24 |
router = APIRouter(
|
| 25 |
prefix="/sales",
|
|
|
|
| 62 |
- field: Field to sort by (default: order_date)
|
| 63 |
- order: Sort order (asc/desc, default: desc)
|
| 64 |
"""
|
| 65 |
+
try:
|
| 66 |
+
result = await SalesOrderService.list_sales_orders(request)
|
| 67 |
+
return paginated_response(
|
| 68 |
+
documents=[doc.dict() if hasattr(doc, 'dict') else doc for doc in result["documents"]],
|
| 69 |
+
total=result["total"],
|
| 70 |
+
page=result["page"],
|
| 71 |
+
limit=result["limit"],
|
| 72 |
+
correlation_id=x_correlation_id
|
| 73 |
+
)
|
| 74 |
+
except HTTPException:
|
| 75 |
+
raise
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error(
|
| 78 |
+
"Error listing sales orders",
|
| 79 |
+
extra={
|
| 80 |
+
"operation": "list_sales_orders",
|
| 81 |
+
"merchant_id": x_merchant_id,
|
| 82 |
+
"error": str(e),
|
| 83 |
+
"filters": str(request.filters) if request.filters else None
|
| 84 |
+
},
|
| 85 |
+
exc_info=True
|
| 86 |
+
)
|
| 87 |
+
raise HTTPException(
|
| 88 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 89 |
+
detail="Error listing sales orders"
|
| 90 |
+
)
|
| 91 |
|
| 92 |
|
| 93 |
@router.get(
|
|
|
|
| 112 |
- Shipment information
|
| 113 |
- Invoice details (if generated)
|
| 114 |
"""
|
| 115 |
+
try:
|
| 116 |
+
return await SalesOrderService.get_sales_order(sales_order_id)
|
| 117 |
+
except HTTPException:
|
| 118 |
+
raise
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(
|
| 121 |
+
f"Error retrieving sales order {sales_order_id}",
|
| 122 |
+
extra={
|
| 123 |
+
"operation": "get_sales_order",
|
| 124 |
+
"sales_order_id": sales_order_id,
|
| 125 |
+
"merchant_id": x_merchant_id,
|
| 126 |
+
"error": str(e)
|
| 127 |
+
},
|
| 128 |
+
exc_info=True
|
| 129 |
+
)
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 132 |
+
detail="Error retrieving sales order"
|
| 133 |
+
)
|
| 134 |
|
| 135 |
|
| 136 |
@router.post(
|
|
|
|
| 175 |
- credit/cod → unpaid (amount_paid = 0)
|
| 176 |
- partial → partial (custom amount_paid)
|
| 177 |
"""
|
| 178 |
+
try:
|
| 179 |
+
sales_order = await SalesOrderService.create_sales_order(payload, x_merchant_id)
|
| 180 |
+
|
| 181 |
+
logger.info(
|
| 182 |
+
"Sales order created",
|
| 183 |
+
extra={
|
| 184 |
+
"operation": "create_sales_order",
|
| 185 |
+
"sales_order_id": sales_order.sales_order_id,
|
| 186 |
+
"merchant_id": x_merchant_id,
|
| 187 |
+
"user_id": payload.created_by
|
| 188 |
+
}
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
return success_response(
|
| 192 |
+
data={
|
| 193 |
+
"sales_order_id": sales_order.sales_order_id,
|
| 194 |
+
"order_number": sales_order.order_number,
|
| 195 |
+
"summary": sales_order.summary.dict()
|
| 196 |
+
},
|
| 197 |
+
message="Sales order created successfully",
|
| 198 |
+
correlation_id=x_correlation_id
|
| 199 |
+
)
|
| 200 |
+
except HTTPException:
|
| 201 |
+
raise
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(
|
| 204 |
+
"Error creating sales order",
|
| 205 |
+
extra={
|
| 206 |
+
"operation": "create_sales_order",
|
| 207 |
+
"merchant_id": x_merchant_id,
|
| 208 |
+
"user_id": payload.created_by,
|
| 209 |
+
"error": str(e)
|
| 210 |
+
},
|
| 211 |
+
exc_info=True
|
| 212 |
+
)
|
| 213 |
+
raise HTTPException(
|
| 214 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 215 |
+
detail="Error creating sales order"
|
| 216 |
+
)
|
| 217 |
|
| 218 |
|
| 219 |
@router.put(
|
|
|
|
| 244 |
- internal_notes: Internal notes
|
| 245 |
- status: Order status
|
| 246 |
"""
|
| 247 |
+
try:
|
| 248 |
+
sales_order = await SalesOrderService.update_sales_order(
|
| 249 |
+
sales_order_id,
|
| 250 |
+
payload,
|
| 251 |
+
x_user_id
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
logger.info(
|
| 255 |
+
f"Sales order {sales_order_id} updated",
|
| 256 |
+
extra={
|
| 257 |
+
"operation": "update_sales_order",
|
| 258 |
+
"sales_order_id": sales_order_id,
|
| 259 |
+
"merchant_id": x_merchant_id,
|
| 260 |
+
"user_id": x_user_id
|
| 261 |
+
}
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
return success_response(
|
| 265 |
+
data={
|
| 266 |
+
"sales_order_id": sales_order.sales_order_id,
|
| 267 |
+
"order_number": sales_order.order_number,
|
| 268 |
+
"summary": sales_order.summary.dict()
|
| 269 |
+
},
|
| 270 |
+
message="Sales order updated successfully",
|
| 271 |
+
correlation_id=x_correlation_id
|
| 272 |
+
)
|
| 273 |
+
except HTTPException:
|
| 274 |
+
raise
|
| 275 |
+
except Exception as e:
|
| 276 |
+
logger.error(
|
| 277 |
+
f"Error updating sales order {sales_order_id}",
|
| 278 |
+
extra={
|
| 279 |
+
"operation": "update_sales_order",
|
| 280 |
+
"sales_order_id": sales_order_id,
|
| 281 |
+
"merchant_id": x_merchant_id,
|
| 282 |
+
"user_id": x_user_id,
|
| 283 |
+
"error": str(e)
|
| 284 |
+
},
|
| 285 |
+
exc_info=True
|
| 286 |
+
)
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 289 |
+
detail="Error updating sales order"
|
| 290 |
+
)
|
| 291 |
|
| 292 |
|
| 293 |
@router.post(
|
|
|
|
| 316 |
- Invoice not already generated
|
| 317 |
- Order must be in confirmed or processing status (not enforced)
|
| 318 |
"""
|
| 319 |
+
try:
|
| 320 |
+
invoice = await SalesOrderService.generate_invoice(sales_order_id, request)
|
| 321 |
+
|
| 322 |
+
logger.info(
|
| 323 |
+
f"Invoice generated for sales order {sales_order_id}",
|
| 324 |
+
extra={
|
| 325 |
+
"operation": "generate_invoice",
|
| 326 |
+
"sales_order_id": sales_order_id,
|
| 327 |
+
"invoice_id": invoice.invoice_id,
|
| 328 |
+
"merchant_id": x_merchant_id
|
| 329 |
+
}
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
return success_response(
|
| 333 |
+
data=invoice.dict(),
|
| 334 |
+
message="Invoice generated successfully",
|
| 335 |
+
correlation_id=x_correlation_id
|
| 336 |
+
)
|
| 337 |
+
except HTTPException:
|
| 338 |
+
raise
|
| 339 |
+
except Exception as e:
|
| 340 |
+
logger.error(
|
| 341 |
+
f"Error generating invoice for sales order {sales_order_id}",
|
| 342 |
+
extra={
|
| 343 |
+
"operation": "generate_invoice",
|
| 344 |
+
"sales_order_id": sales_order_id,
|
| 345 |
+
"merchant_id": x_merchant_id,
|
| 346 |
+
"error": str(e)
|
| 347 |
+
},
|
| 348 |
+
exc_info=True
|
| 349 |
+
)
|
| 350 |
+
raise HTTPException(
|
| 351 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 352 |
+
detail="Error generating invoice"
|
| 353 |
+
)
|
| 354 |
|
| 355 |
|
| 356 |
@router.get(
|
|
|
|
| 374 |
|
| 375 |
All metrics are scoped to the merchant.
|
| 376 |
"""
|
| 377 |
+
try:
|
| 378 |
+
return await SalesOrderService.get_sales_widgets(x_merchant_id)
|
| 379 |
+
except HTTPException:
|
| 380 |
+
raise
|
| 381 |
+
except Exception as e:
|
| 382 |
+
logger.error(
|
| 383 |
+
"Error retrieving sales widgets",
|
| 384 |
+
extra={
|
| 385 |
+
"operation": "get_sales_widgets",
|
| 386 |
+
"merchant_id": x_merchant_id,
|
| 387 |
+
"error": str(e)
|
| 388 |
+
},
|
| 389 |
+
exc_info=True
|
| 390 |
+
)
|
| 391 |
+
raise HTTPException(
|
| 392 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 393 |
+
detail="Error retrieving sales widgets"
|
| 394 |
+
)
|
app/sales/orders/services/service.py
CHANGED
|
@@ -5,8 +5,7 @@ from datetime import datetime
|
|
| 5 |
from typing import Optional, List, Dict, Any
|
| 6 |
from decimal import Decimal
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
-
|
| 9 |
-
import logging
|
| 10 |
import secrets
|
| 11 |
|
| 12 |
from app.nosql import get_database
|
|
@@ -30,8 +29,7 @@ from app.sales.orders.schemas.schema import (
|
|
| 30 |
FulfillmentStatus
|
| 31 |
)
|
| 32 |
|
| 33 |
-
|
| 34 |
-
logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
|
| 37 |
def generate_sales_order_id() -> str:
|
|
@@ -245,6 +243,7 @@ class SalesOrderService:
|
|
| 245 |
logger.info(
|
| 246 |
f"Created sales order {sales_order_id}",
|
| 247 |
extra={
|
|
|
|
| 248 |
"sales_order_id": sales_order_id,
|
| 249 |
"order_number": order_number,
|
| 250 |
"merchant_id": merchant_id,
|
|
@@ -255,7 +254,15 @@ class SalesOrderService:
|
|
| 255 |
return SalesOrderResponse(**sales_order)
|
| 256 |
|
| 257 |
except Exception as e:
|
| 258 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
raise HTTPException(
|
| 260 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 261 |
detail=f"Error creating sales order: {str(e)}"
|
|
@@ -264,17 +271,34 @@ class SalesOrderService:
|
|
| 264 |
@staticmethod
|
| 265 |
async def get_sales_order(sales_order_id: str) -> SalesOrderResponse:
|
| 266 |
"""Get sales order by ID."""
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
raise HTTPException(
|
| 273 |
-
status_code=status.
|
| 274 |
-
detail=f"
|
| 275 |
)
|
| 276 |
-
|
| 277 |
-
return SalesOrderResponse(**sales_order)
|
| 278 |
|
| 279 |
@staticmethod
|
| 280 |
async def list_sales_orders(request: SalesOrderListRequest) -> Dict[str, Any]:
|
|
@@ -344,7 +368,15 @@ class SalesOrderService:
|
|
| 344 |
}
|
| 345 |
|
| 346 |
except Exception as e:
|
| 347 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
raise HTTPException(
|
| 349 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 350 |
detail="Error listing sales orders"
|
|
@@ -405,6 +437,7 @@ class SalesOrderService:
|
|
| 405 |
logger.info(
|
| 406 |
f"Updated sales order {sales_order_id}",
|
| 407 |
extra={
|
|
|
|
| 408 |
"sales_order_id": sales_order_id,
|
| 409 |
"updated_by": updated_by
|
| 410 |
}
|
|
@@ -415,7 +448,16 @@ class SalesOrderService:
|
|
| 415 |
except HTTPException:
|
| 416 |
raise
|
| 417 |
except Exception as e:
|
| 418 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
raise HTTPException(
|
| 420 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 421 |
detail=f"Error updating sales order: {str(e)}"
|
|
@@ -464,6 +506,7 @@ class SalesOrderService:
|
|
| 464 |
logger.info(
|
| 465 |
f"Generated invoice for sales order {sales_order_id}",
|
| 466 |
extra={
|
|
|
|
| 467 |
"sales_order_id": sales_order_id,
|
| 468 |
"invoice_id": invoice_id,
|
| 469 |
"invoice_number": invoice_number
|
|
@@ -478,7 +521,15 @@ class SalesOrderService:
|
|
| 478 |
)
|
| 479 |
|
| 480 |
except Exception as e:
|
| 481 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
raise HTTPException(
|
| 483 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 484 |
detail="Error generating invoice"
|
|
@@ -529,7 +580,15 @@ class SalesOrderService:
|
|
| 529 |
)
|
| 530 |
|
| 531 |
except Exception as e:
|
| 532 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
raise HTTPException(
|
| 534 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 535 |
detail="Error fetching sales widgets"
|
|
|
|
| 5 |
from typing import Optional, List, Dict, Any
|
| 6 |
from decimal import Decimal
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
+
from app.core.logging import get_logger
|
|
|
|
| 9 |
import secrets
|
| 10 |
|
| 11 |
from app.nosql import get_database
|
|
|
|
| 29 |
FulfillmentStatus
|
| 30 |
)
|
| 31 |
|
| 32 |
+
logger = get_logger(__name__)
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def generate_sales_order_id() -> str:
|
|
|
|
| 243 |
logger.info(
|
| 244 |
f"Created sales order {sales_order_id}",
|
| 245 |
extra={
|
| 246 |
+
"operation": "create_sales_order",
|
| 247 |
"sales_order_id": sales_order_id,
|
| 248 |
"order_number": order_number,
|
| 249 |
"merchant_id": merchant_id,
|
|
|
|
| 254 |
return SalesOrderResponse(**sales_order)
|
| 255 |
|
| 256 |
except Exception as e:
|
| 257 |
+
logger.error(
|
| 258 |
+
"Error creating sales order",
|
| 259 |
+
extra={
|
| 260 |
+
"operation": "create_sales_order",
|
| 261 |
+
"error": str(e),
|
| 262 |
+
"merchant_id": merchant_id
|
| 263 |
+
},
|
| 264 |
+
exc_info=True
|
| 265 |
+
)
|
| 266 |
raise HTTPException(
|
| 267 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 268 |
detail=f"Error creating sales order: {str(e)}"
|
|
|
|
| 271 |
@staticmethod
|
| 272 |
async def get_sales_order(sales_order_id: str) -> SalesOrderResponse:
|
| 273 |
"""Get sales order by ID."""
|
| 274 |
+
try:
|
| 275 |
+
sales_order = await get_database()[SCM_SALES_ORDERS_COLLECTION].find_one(
|
| 276 |
+
{"sales_order_id": sales_order_id}
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
if not sales_order:
|
| 280 |
+
raise HTTPException(
|
| 281 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 282 |
+
detail=f"Sales order {sales_order_id} not found"
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
return SalesOrderResponse(**sales_order)
|
| 286 |
+
except HTTPException:
|
| 287 |
+
raise
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(
|
| 290 |
+
f"Error getting sales order {sales_order_id}",
|
| 291 |
+
extra={
|
| 292 |
+
"operation": "get_sales_order",
|
| 293 |
+
"sales_order_id": sales_order_id,
|
| 294 |
+
"error": str(e)
|
| 295 |
+
},
|
| 296 |
+
exc_info=True
|
| 297 |
+
)
|
| 298 |
raise HTTPException(
|
| 299 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 300 |
+
detail=f"Error getting sales order: {str(e)}"
|
| 301 |
)
|
|
|
|
|
|
|
| 302 |
|
| 303 |
@staticmethod
|
| 304 |
async def list_sales_orders(request: SalesOrderListRequest) -> Dict[str, Any]:
|
|
|
|
| 368 |
}
|
| 369 |
|
| 370 |
except Exception as e:
|
| 371 |
+
logger.error(
|
| 372 |
+
"Error listing sales orders",
|
| 373 |
+
extra={
|
| 374 |
+
"operation": "list_sales_orders",
|
| 375 |
+
"error": str(e),
|
| 376 |
+
"filters": str(request.filters) if request.filters else None
|
| 377 |
+
},
|
| 378 |
+
exc_info=True
|
| 379 |
+
)
|
| 380 |
raise HTTPException(
|
| 381 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 382 |
detail="Error listing sales orders"
|
|
|
|
| 437 |
logger.info(
|
| 438 |
f"Updated sales order {sales_order_id}",
|
| 439 |
extra={
|
| 440 |
+
"operation": "update_sales_order",
|
| 441 |
"sales_order_id": sales_order_id,
|
| 442 |
"updated_by": updated_by
|
| 443 |
}
|
|
|
|
| 448 |
except HTTPException:
|
| 449 |
raise
|
| 450 |
except Exception as e:
|
| 451 |
+
logger.error(
|
| 452 |
+
f"Error updating sales order {sales_order_id}",
|
| 453 |
+
extra={
|
| 454 |
+
"operation": "update_sales_order",
|
| 455 |
+
"sales_order_id": sales_order_id,
|
| 456 |
+
"error": str(e),
|
| 457 |
+
"updated_by": updated_by
|
| 458 |
+
},
|
| 459 |
+
exc_info=True
|
| 460 |
+
)
|
| 461 |
raise HTTPException(
|
| 462 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 463 |
detail=f"Error updating sales order: {str(e)}"
|
|
|
|
| 506 |
logger.info(
|
| 507 |
f"Generated invoice for sales order {sales_order_id}",
|
| 508 |
extra={
|
| 509 |
+
"operation": "generate_invoice",
|
| 510 |
"sales_order_id": sales_order_id,
|
| 511 |
"invoice_id": invoice_id,
|
| 512 |
"invoice_number": invoice_number
|
|
|
|
| 521 |
)
|
| 522 |
|
| 523 |
except Exception as e:
|
| 524 |
+
logger.error(
|
| 525 |
+
"Error generating invoice",
|
| 526 |
+
extra={
|
| 527 |
+
"operation": "generate_invoice",
|
| 528 |
+
"sales_order_id": sales_order_id,
|
| 529 |
+
"error": str(e)
|
| 530 |
+
},
|
| 531 |
+
exc_info=True
|
| 532 |
+
)
|
| 533 |
raise HTTPException(
|
| 534 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 535 |
detail="Error generating invoice"
|
|
|
|
| 580 |
)
|
| 581 |
|
| 582 |
except Exception as e:
|
| 583 |
+
logger.error(
|
| 584 |
+
"Error getting sales widgets",
|
| 585 |
+
extra={
|
| 586 |
+
"operation": "get_sales_widgets",
|
| 587 |
+
"error": str(e),
|
| 588 |
+
"merchant_id": merchant_id
|
| 589 |
+
},
|
| 590 |
+
exc_info=True
|
| 591 |
+
)
|
| 592 |
raise HTTPException(
|
| 593 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 594 |
detail="Error fetching sales widgets"
|
app/sales/retail/controllers/router.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Retail Sales API router.
|
| 3 |
"""
|
| 4 |
-
|
| 5 |
from uuid import UUID
|
| 6 |
from fastapi import APIRouter, HTTPException, status, Depends
|
| 7 |
|
|
@@ -27,7 +27,7 @@ from app.sales.retail.services.service import (
|
|
| 27 |
list_sales,
|
| 28 |
)
|
| 29 |
|
| 30 |
-
logger =
|
| 31 |
|
| 32 |
router = APIRouter(
|
| 33 |
prefix="/pos/sales",
|
|
@@ -53,20 +53,75 @@ async def create_sale_endpoint(
|
|
| 53 |
payment_tenders=[t.model_dump() for t in req.payment_tenders] if req.payment_tenders else None,
|
| 54 |
)
|
| 55 |
sale = await get_sale(sid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
return await _to_sale_response(sale)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
except Exception as e:
|
| 58 |
-
logger.error(
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
@router.get("/{sale_id}", response_model=SaleResponse)
|
| 62 |
async def get_sale_endpoint(
|
| 63 |
sale_id: UUID,
|
| 64 |
current_user: TokenUser = Depends(require_pos_permission("sales", "view"))
|
| 65 |
):
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
@router.post("/list", response_model=SaleListResponse, summary="List sales")
|
| 72 |
async def list_sales_endpoint(
|
|
@@ -93,30 +148,54 @@ async def list_sales_endpoint(
|
|
| 93 |
- skip: Records to skip (default: 0)
|
| 94 |
- limit: Max records to return (default: 50, max: 500)
|
| 95 |
"""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
@router.put("/{sale_id}/items", response_model=SaleResponse)
|
| 122 |
async def update_items_endpoint(
|
|
@@ -126,8 +205,40 @@ async def update_items_endpoint(
|
|
| 126 |
):
|
| 127 |
try:
|
| 128 |
sale = await replace_items(sale_id, [i.model_dump() for i in req.items])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
return await _to_sale_response(sale)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
raise HTTPException(status_code=400, detail=str(e))
|
| 132 |
|
| 133 |
@router.post("/{sale_id}/payments", response_model=SaleResponse)
|
|
@@ -146,8 +257,41 @@ async def capture_payment_endpoint(
|
|
| 146 |
req.gst_amount,
|
| 147 |
req.gst_rate
|
| 148 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
return await _to_sale_response(sale)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
raise HTTPException(status_code=400, detail=str(e))
|
| 152 |
|
| 153 |
@router.post("/{sale_id}/cancel", response_model=SaleResponse)
|
|
@@ -157,8 +301,40 @@ async def cancel_sale_endpoint(
|
|
| 157 |
):
|
| 158 |
try:
|
| 159 |
sale = await cancel_sale(sale_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
return await _to_sale_response(sale)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
raise HTTPException(status_code=400, detail=str(e))
|
| 163 |
|
| 164 |
@router.post("/{sale_id}/refund", response_model=SaleResponse)
|
|
@@ -169,8 +345,41 @@ async def refund_sale_endpoint(
|
|
| 169 |
):
|
| 170 |
try:
|
| 171 |
sale = await refund_sale(sale_id, req.amount, req.reason, req.refunded_by)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
return await _to_sale_response(sale)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
raise HTTPException(status_code=400, detail=str(e))
|
| 175 |
|
| 176 |
async def _to_sale_response(sale: dict) -> SaleResponse:
|
|
|
|
| 1 |
"""
|
| 2 |
Retail Sales API router.
|
| 3 |
"""
|
| 4 |
+
from app.core.logging import get_logger
|
| 5 |
from uuid import UUID
|
| 6 |
from fastapi import APIRouter, HTTPException, status, Depends
|
| 7 |
|
|
|
|
| 27 |
list_sales,
|
| 28 |
)
|
| 29 |
|
| 30 |
+
logger = get_logger(__name__)
|
| 31 |
|
| 32 |
router = APIRouter(
|
| 33 |
prefix="/pos/sales",
|
|
|
|
| 53 |
payment_tenders=[t.model_dump() for t in req.payment_tenders] if req.payment_tenders else None,
|
| 54 |
)
|
| 55 |
sale = await get_sale(sid)
|
| 56 |
+
|
| 57 |
+
logger.info(
|
| 58 |
+
"Sale created",
|
| 59 |
+
extra={
|
| 60 |
+
"operation": "create_sale",
|
| 61 |
+
"sale_id": str(sid),
|
| 62 |
+
"merchant_id": str(current_user.merchant_id),
|
| 63 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
return await _to_sale_response(sale)
|
| 68 |
+
except HTTPException:
|
| 69 |
+
raise
|
| 70 |
+
except ValueError as e:
|
| 71 |
+
logger.warning(
|
| 72 |
+
"Create sale validation failed",
|
| 73 |
+
extra={
|
| 74 |
+
"operation": "create_sale",
|
| 75 |
+
"error": str(e),
|
| 76 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 77 |
+
}
|
| 78 |
+
)
|
| 79 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
| 80 |
except Exception as e:
|
| 81 |
+
logger.error(
|
| 82 |
+
"Create sale failed",
|
| 83 |
+
extra={
|
| 84 |
+
"operation": "create_sale",
|
| 85 |
+
"error": str(e),
|
| 86 |
+
"error_type": type(e).__name__,
|
| 87 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 88 |
+
},
|
| 89 |
+
exc_info=True
|
| 90 |
+
)
|
| 91 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create sale")
|
| 92 |
|
| 93 |
@router.get("/{sale_id}", response_model=SaleResponse)
|
| 94 |
async def get_sale_endpoint(
|
| 95 |
sale_id: UUID,
|
| 96 |
current_user: TokenUser = Depends(require_pos_permission("sales", "view"))
|
| 97 |
):
|
| 98 |
+
try:
|
| 99 |
+
sale = await get_sale(sale_id)
|
| 100 |
+
if not sale:
|
| 101 |
+
logger.warning(
|
| 102 |
+
"Sale not found",
|
| 103 |
+
extra={
|
| 104 |
+
"operation": "get_sale",
|
| 105 |
+
"sale_id": str(sale_id),
|
| 106 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 107 |
+
}
|
| 108 |
+
)
|
| 109 |
+
raise HTTPException(status_code=404, detail="Sale not found")
|
| 110 |
+
return await _to_sale_response(sale)
|
| 111 |
+
except HTTPException:
|
| 112 |
+
raise
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(
|
| 115 |
+
"Get sale failed",
|
| 116 |
+
extra={
|
| 117 |
+
"operation": "get_sale",
|
| 118 |
+
"sale_id": str(sale_id),
|
| 119 |
+
"error": str(e),
|
| 120 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 121 |
+
},
|
| 122 |
+
exc_info=True
|
| 123 |
+
)
|
| 124 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get sale")
|
| 125 |
|
| 126 |
@router.post("/list", response_model=SaleListResponse, summary="List sales")
|
| 127 |
async def list_sales_endpoint(
|
|
|
|
| 148 |
- skip: Records to skip (default: 0)
|
| 149 |
- limit: Max records to return (default: 50, max: 500)
|
| 150 |
"""
|
| 151 |
+
try:
|
| 152 |
+
# Extract filters and parameters from payload
|
| 153 |
+
filters = payload.filters or {}
|
| 154 |
+
skip = payload.skip or 0
|
| 155 |
+
limit = payload.limit or 50
|
| 156 |
+
projection_list = payload.projection_list
|
| 157 |
+
|
| 158 |
+
# Always use merchant_id from token for security
|
| 159 |
+
if not current_user.merchant_id:
|
| 160 |
+
raise HTTPException(status_code=400, detail="merchant_id must be available in token")
|
| 161 |
+
|
| 162 |
+
sales, total = await list_sales(
|
| 163 |
+
merchant_id=current_user.merchant_id,
|
| 164 |
+
filters=filters,
|
| 165 |
+
skip=skip,
|
| 166 |
+
limit=limit,
|
| 167 |
+
projection_list=projection_list
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
logger.info(
|
| 171 |
+
"Sales listed",
|
| 172 |
+
extra={
|
| 173 |
+
"operation": "list_sales",
|
| 174 |
+
"count": len(sales),
|
| 175 |
+
"total": total,
|
| 176 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 177 |
+
}
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
return SaleListResponse(
|
| 181 |
+
sales=sales, # Raw dicts when projection is used, full objects otherwise
|
| 182 |
+
total=total,
|
| 183 |
+
skip=skip,
|
| 184 |
+
limit=limit
|
| 185 |
+
)
|
| 186 |
+
except HTTPException:
|
| 187 |
+
raise
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(
|
| 190 |
+
"List sales failed",
|
| 191 |
+
extra={
|
| 192 |
+
"operation": "list_sales",
|
| 193 |
+
"error": str(e),
|
| 194 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 195 |
+
},
|
| 196 |
+
exc_info=True
|
| 197 |
+
)
|
| 198 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to list sales")
|
| 199 |
|
| 200 |
@router.put("/{sale_id}/items", response_model=SaleResponse)
|
| 201 |
async def update_items_endpoint(
|
|
|
|
| 205 |
):
|
| 206 |
try:
|
| 207 |
sale = await replace_items(sale_id, [i.model_dump() for i in req.items])
|
| 208 |
+
|
| 209 |
+
logger.info(
|
| 210 |
+
"Sale items updated",
|
| 211 |
+
extra={
|
| 212 |
+
"operation": "update_sale_items",
|
| 213 |
+
"sale_id": str(sale_id),
|
| 214 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None,
|
| 215 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 216 |
+
}
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
return await _to_sale_response(sale)
|
| 220 |
+
except ValueError as e:
|
| 221 |
+
logger.warning(
|
| 222 |
+
"Update sale items validation failed",
|
| 223 |
+
extra={
|
| 224 |
+
"operation": "update_sale_items",
|
| 225 |
+
"sale_id": str(sale_id),
|
| 226 |
+
"error": str(e),
|
| 227 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 228 |
+
}
|
| 229 |
+
)
|
| 230 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 231 |
except Exception as e:
|
| 232 |
+
logger.error(
|
| 233 |
+
"Update sale items failed",
|
| 234 |
+
extra={
|
| 235 |
+
"operation": "update_sale_items",
|
| 236 |
+
"sale_id": str(sale_id),
|
| 237 |
+
"error": str(e),
|
| 238 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 239 |
+
},
|
| 240 |
+
exc_info=True
|
| 241 |
+
)
|
| 242 |
raise HTTPException(status_code=400, detail=str(e))
|
| 243 |
|
| 244 |
@router.post("/{sale_id}/payments", response_model=SaleResponse)
|
|
|
|
| 257 |
req.gst_amount,
|
| 258 |
req.gst_rate
|
| 259 |
)
|
| 260 |
+
|
| 261 |
+
logger.info(
|
| 262 |
+
"Payment captured",
|
| 263 |
+
extra={
|
| 264 |
+
"operation": "capture_payment",
|
| 265 |
+
"sale_id": str(sale_id),
|
| 266 |
+
"amount": req.amount,
|
| 267 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None,
|
| 268 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 269 |
+
}
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
return await _to_sale_response(sale)
|
| 273 |
+
except ValueError as e:
|
| 274 |
+
logger.warning(
|
| 275 |
+
"Capture payment validation failed",
|
| 276 |
+
extra={
|
| 277 |
+
"operation": "capture_payment",
|
| 278 |
+
"sale_id": str(sale_id),
|
| 279 |
+
"error": str(e),
|
| 280 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 281 |
+
}
|
| 282 |
+
)
|
| 283 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 284 |
except Exception as e:
|
| 285 |
+
logger.error(
|
| 286 |
+
"Capture payment failed",
|
| 287 |
+
extra={
|
| 288 |
+
"operation": "capture_payment",
|
| 289 |
+
"sale_id": str(sale_id),
|
| 290 |
+
"error": str(e),
|
| 291 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 292 |
+
},
|
| 293 |
+
exc_info=True
|
| 294 |
+
)
|
| 295 |
raise HTTPException(status_code=400, detail=str(e))
|
| 296 |
|
| 297 |
@router.post("/{sale_id}/cancel", response_model=SaleResponse)
|
|
|
|
| 301 |
):
|
| 302 |
try:
|
| 303 |
sale = await cancel_sale(sale_id)
|
| 304 |
+
|
| 305 |
+
logger.info(
|
| 306 |
+
"Sale cancelled",
|
| 307 |
+
extra={
|
| 308 |
+
"operation": "cancel_sale",
|
| 309 |
+
"sale_id": str(sale_id),
|
| 310 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None,
|
| 311 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 312 |
+
}
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
return await _to_sale_response(sale)
|
| 316 |
+
except ValueError as e:
|
| 317 |
+
logger.warning(
|
| 318 |
+
"Cancel sale validation failed",
|
| 319 |
+
extra={
|
| 320 |
+
"operation": "cancel_sale",
|
| 321 |
+
"sale_id": str(sale_id),
|
| 322 |
+
"error": str(e),
|
| 323 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 324 |
+
}
|
| 325 |
+
)
|
| 326 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 327 |
except Exception as e:
|
| 328 |
+
logger.error(
|
| 329 |
+
"Cancel sale failed",
|
| 330 |
+
extra={
|
| 331 |
+
"operation": "cancel_sale",
|
| 332 |
+
"sale_id": str(sale_id),
|
| 333 |
+
"error": str(e),
|
| 334 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 335 |
+
},
|
| 336 |
+
exc_info=True
|
| 337 |
+
)
|
| 338 |
raise HTTPException(status_code=400, detail=str(e))
|
| 339 |
|
| 340 |
@router.post("/{sale_id}/refund", response_model=SaleResponse)
|
|
|
|
| 345 |
):
|
| 346 |
try:
|
| 347 |
sale = await refund_sale(sale_id, req.amount, req.reason, req.refunded_by)
|
| 348 |
+
|
| 349 |
+
logger.info(
|
| 350 |
+
"Sale refunded",
|
| 351 |
+
extra={
|
| 352 |
+
"operation": "refund_sale",
|
| 353 |
+
"sale_id": str(sale_id),
|
| 354 |
+
"amount": req.amount,
|
| 355 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None,
|
| 356 |
+
"user_id": str(current_user.id) if current_user.id else None
|
| 357 |
+
}
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
return await _to_sale_response(sale)
|
| 361 |
+
except ValueError as e:
|
| 362 |
+
logger.warning(
|
| 363 |
+
"Refund sale validation failed",
|
| 364 |
+
extra={
|
| 365 |
+
"operation": "refund_sale",
|
| 366 |
+
"sale_id": str(sale_id),
|
| 367 |
+
"error": str(e),
|
| 368 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 369 |
+
}
|
| 370 |
+
)
|
| 371 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 372 |
except Exception as e:
|
| 373 |
+
logger.error(
|
| 374 |
+
"Refund sale failed",
|
| 375 |
+
extra={
|
| 376 |
+
"operation": "refund_sale",
|
| 377 |
+
"sale_id": str(sale_id),
|
| 378 |
+
"error": str(e),
|
| 379 |
+
"merchant_id": str(current_user.merchant_id) if current_user.merchant_id else None
|
| 380 |
+
},
|
| 381 |
+
exc_info=True
|
| 382 |
+
)
|
| 383 |
raise HTTPException(status_code=400, detail=str(e))
|
| 384 |
|
| 385 |
async def _to_sale_response(sale: dict) -> SaleResponse:
|
app/sales/retail/services/service.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
Retail Sales service: business logic and Postgres operations.
|
| 3 |
"""
|
| 4 |
-
|
| 5 |
from uuid import uuid4, UUID
|
| 6 |
from typing import Optional, List, Tuple
|
| 7 |
from sqlalchemy import text
|
| 8 |
|
| 9 |
from app.sql import get_postgres_session
|
| 10 |
|
| 11 |
-
logger =
|
| 12 |
|
| 13 |
# Helper to compute taxes (placeholder: 18% GST)
|
| 14 |
TAX_RATE = 0.18
|
|
@@ -148,11 +148,27 @@ async def create_sale(
|
|
| 148 |
|
| 149 |
await session.commit()
|
| 150 |
|
| 151 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
return sale_id
|
| 153 |
except Exception as e:
|
| 154 |
await session.rollback()
|
| 155 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
raise
|
| 157 |
|
| 158 |
async def get_sale(sale_id: UUID) -> dict:
|
|
@@ -250,7 +266,15 @@ async def replace_items(sale_id: UUID, items: List[dict]) -> dict:
|
|
| 250 |
await session.commit()
|
| 251 |
except Exception as e:
|
| 252 |
await session.rollback()
|
| 253 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
raise
|
| 255 |
return await get_sale(sale_id)
|
| 256 |
|
|
@@ -295,7 +319,16 @@ async def capture_payment(sale_id: UUID, mode: str, amount: float, reference_no:
|
|
| 295 |
await session.commit()
|
| 296 |
except Exception as e:
|
| 297 |
await session.rollback()
|
| 298 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
raise
|
| 300 |
return await get_sale(sale_id)
|
| 301 |
|
|
@@ -354,7 +387,16 @@ async def refund_sale(sale_id: UUID, amount: float, reason: Optional[str], refun
|
|
| 354 |
await session.commit()
|
| 355 |
except Exception as e:
|
| 356 |
await session.rollback()
|
| 357 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
raise
|
| 359 |
return await get_sale(sale_id)
|
| 360 |
|
|
@@ -476,5 +518,5 @@ async def list_sales(
|
|
| 476 |
{"sid": str(sale_id)}
|
| 477 |
)
|
| 478 |
sale["refunds"] = [dict(r._mapping) for r in ref_rs.fetchall()]
|
| 479 |
-
|
| 480 |
return sales, total
|
|
|
|
| 1 |
"""
|
| 2 |
Retail Sales service: business logic and Postgres operations.
|
| 3 |
"""
|
| 4 |
+
from app.core.logging import get_logger
|
| 5 |
from uuid import uuid4, UUID
|
| 6 |
from typing import Optional, List, Tuple
|
| 7 |
from sqlalchemy import text
|
| 8 |
|
| 9 |
from app.sql import get_postgres_session
|
| 10 |
|
| 11 |
+
logger = get_logger(__name__)
|
| 12 |
|
| 13 |
# Helper to compute taxes (placeholder: 18% GST)
|
| 14 |
TAX_RATE = 0.18
|
|
|
|
| 148 |
|
| 149 |
await session.commit()
|
| 150 |
|
| 151 |
+
logger.info(
|
| 152 |
+
f"Created sale {sale_id} with {len(enriched_items)} items",
|
| 153 |
+
extra={
|
| 154 |
+
"operation": "create_sale",
|
| 155 |
+
"sale_id": str(sale_id),
|
| 156 |
+
"merchant_id": str(merchant_id),
|
| 157 |
+
"items_count": len(enriched_items)
|
| 158 |
+
}
|
| 159 |
+
)
|
| 160 |
return sale_id
|
| 161 |
except Exception as e:
|
| 162 |
await session.rollback()
|
| 163 |
+
logger.error(
|
| 164 |
+
f"Failed to create sale: {e}",
|
| 165 |
+
extra={
|
| 166 |
+
"operation": "create_sale",
|
| 167 |
+
"merchant_id": str(merchant_id),
|
| 168 |
+
"error": str(e)
|
| 169 |
+
},
|
| 170 |
+
exc_info=True
|
| 171 |
+
)
|
| 172 |
raise
|
| 173 |
|
| 174 |
async def get_sale(sale_id: UUID) -> dict:
|
|
|
|
| 266 |
await session.commit()
|
| 267 |
except Exception as e:
|
| 268 |
await session.rollback()
|
| 269 |
+
logger.error(
|
| 270 |
+
f"Failed to update items: {e}",
|
| 271 |
+
extra={
|
| 272 |
+
"operation": "replace_items",
|
| 273 |
+
"sale_id": str(sale_id),
|
| 274 |
+
"error": str(e)
|
| 275 |
+
},
|
| 276 |
+
exc_info=True
|
| 277 |
+
)
|
| 278 |
raise
|
| 279 |
return await get_sale(sale_id)
|
| 280 |
|
|
|
|
| 319 |
await session.commit()
|
| 320 |
except Exception as e:
|
| 321 |
await session.rollback()
|
| 322 |
+
logger.error(
|
| 323 |
+
f"Payment capture failed: {e}",
|
| 324 |
+
extra={
|
| 325 |
+
"operation": "capture_payment",
|
| 326 |
+
"sale_id": str(sale_id),
|
| 327 |
+
"amount": amount,
|
| 328 |
+
"error": str(e)
|
| 329 |
+
},
|
| 330 |
+
exc_info=True
|
| 331 |
+
)
|
| 332 |
raise
|
| 333 |
return await get_sale(sale_id)
|
| 334 |
|
|
|
|
| 387 |
await session.commit()
|
| 388 |
except Exception as e:
|
| 389 |
await session.rollback()
|
| 390 |
+
logger.error(
|
| 391 |
+
f"Refund failed: {e}",
|
| 392 |
+
extra={
|
| 393 |
+
"operation": "refund_sale",
|
| 394 |
+
"sale_id": str(sale_id),
|
| 395 |
+
"amount": amount,
|
| 396 |
+
"error": str(e)
|
| 397 |
+
},
|
| 398 |
+
exc_info=True
|
| 399 |
+
)
|
| 400 |
raise
|
| 401 |
return await get_sale(sale_id)
|
| 402 |
|
|
|
|
| 518 |
{"sid": str(sale_id)}
|
| 519 |
)
|
| 520 |
sale["refunds"] = [dict(r._mapping) for r in ref_rs.fetchall()]
|
| 521 |
+
|
| 522 |
return sales, total
|
app/sales/returns/controllers/router.py
CHANGED
|
@@ -3,8 +3,7 @@ RMA (Return Merchandise Authorization) API router - FastAPI endpoints for RMA op
|
|
| 3 |
"""
|
| 4 |
from typing import Optional, List
|
| 5 |
from fastapi import APIRouter, HTTPException, Query, status
|
| 6 |
-
|
| 7 |
-
import logging
|
| 8 |
|
| 9 |
from app.sales.returns.schemas.schema import (
|
| 10 |
RMACreate,
|
|
@@ -18,8 +17,7 @@ from app.sales.returns.schemas.schema import (
|
|
| 18 |
)
|
| 19 |
from app.sales.returns.services.service import RMAService
|
| 20 |
|
| 21 |
-
|
| 22 |
-
logger = logging.getLogger(__name__)
|
| 23 |
|
| 24 |
router = APIRouter(
|
| 25 |
prefix="/rma",
|
|
@@ -47,8 +45,36 @@ async def create_rma(payload: RMACreate):
|
|
| 47 |
- Order exists and items are valid
|
| 48 |
- Return window is within policy
|
| 49 |
"""
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
@router.get(
|
|
@@ -60,8 +86,25 @@ async def get_rma(rma_id: str):
|
|
| 60 |
"""
|
| 61 |
Retrieve an RMA by ID.
|
| 62 |
"""
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
@router.put(
|
|
@@ -74,8 +117,38 @@ async def update_rma(rma_id: str, payload: RMAUpdate):
|
|
| 74 |
Update an RMA.
|
| 75 |
Only allowed for requested or approved RMAs.
|
| 76 |
"""
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
@router.post(
|
|
@@ -92,8 +165,41 @@ async def approve_rma(rma_id: str, payload: RMAApproveRequest):
|
|
| 92 |
- **approved_action**: Final approved action (may differ from requested)
|
| 93 |
- **return_window_days**: Days allowed for return
|
| 94 |
"""
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
@router.post(
|
|
@@ -110,8 +216,39 @@ async def schedule_pickup(rma_id: str, payload: RMAPickupRequest):
|
|
| 110 |
- **pickup_address**: Address for pickup
|
| 111 |
- **pickup_contact**: Contact person details
|
| 112 |
"""
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
@router.post(
|
|
@@ -134,8 +271,40 @@ async def inspect_rma(rma_id: str, payload: RMAInspectRequest):
|
|
| 134 |
- Issue refund/credit note based on inspection
|
| 135 |
- Close RMA if fully processed
|
| 136 |
"""
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
@router.get(
|
|
@@ -153,14 +322,31 @@ async def list_rmas(
|
|
| 153 |
"""
|
| 154 |
List RMAs with optional filters.
|
| 155 |
"""
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
@router.delete(
|
|
@@ -176,5 +362,37 @@ async def cancel_rma(
|
|
| 176 |
Cancel an RMA.
|
| 177 |
Only allowed for requested or approved RMAs.
|
| 178 |
"""
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
from typing import Optional, List
|
| 5 |
from fastapi import APIRouter, HTTPException, Query, status
|
| 6 |
+
from app.core.logging import get_logger
|
|
|
|
| 7 |
|
| 8 |
from app.sales.returns.schemas.schema import (
|
| 9 |
RMACreate,
|
|
|
|
| 17 |
)
|
| 18 |
from app.sales.returns.services.service import RMAService
|
| 19 |
|
| 20 |
+
logger = get_logger(__name__)
|
|
|
|
| 21 |
|
| 22 |
router = APIRouter(
|
| 23 |
prefix="/rma",
|
|
|
|
| 45 |
- Order exists and items are valid
|
| 46 |
- Return window is within policy
|
| 47 |
"""
|
| 48 |
+
try:
|
| 49 |
+
rma = await RMAService.create_rma(payload)
|
| 50 |
+
|
| 51 |
+
logger.info(
|
| 52 |
+
"RMA created",
|
| 53 |
+
extra={
|
| 54 |
+
"operation": "create_rma",
|
| 55 |
+
"rma_id": rma.get("rma_id"),
|
| 56 |
+
"merchant_id": payload.merchant_id,
|
| 57 |
+
"user_id": payload.created_by
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
return RMAResponse(**rma)
|
| 62 |
+
except HTTPException:
|
| 63 |
+
raise
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(
|
| 66 |
+
"Error creating RMA",
|
| 67 |
+
extra={
|
| 68 |
+
"operation": "create_rma",
|
| 69 |
+
"error": str(e),
|
| 70 |
+
"merchant_id": payload.merchant_id
|
| 71 |
+
},
|
| 72 |
+
exc_info=True
|
| 73 |
+
)
|
| 74 |
+
raise HTTPException(
|
| 75 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 76 |
+
detail="Error creating RMA"
|
| 77 |
+
)
|
| 78 |
|
| 79 |
|
| 80 |
@router.get(
|
|
|
|
| 86 |
"""
|
| 87 |
Retrieve an RMA by ID.
|
| 88 |
"""
|
| 89 |
+
try:
|
| 90 |
+
rma = await RMAService.get_rma(rma_id)
|
| 91 |
+
return RMAResponse(**rma)
|
| 92 |
+
except HTTPException:
|
| 93 |
+
raise
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(
|
| 96 |
+
f"Error getting RMA {rma_id}",
|
| 97 |
+
extra={
|
| 98 |
+
"operation": "get_rma",
|
| 99 |
+
"rma_id": rma_id,
|
| 100 |
+
"error": str(e)
|
| 101 |
+
},
|
| 102 |
+
exc_info=True
|
| 103 |
+
)
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 106 |
+
detail=f"Error getting RMA {rma_id}"
|
| 107 |
+
)
|
| 108 |
|
| 109 |
|
| 110 |
@router.put(
|
|
|
|
| 117 |
Update an RMA.
|
| 118 |
Only allowed for requested or approved RMAs.
|
| 119 |
"""
|
| 120 |
+
try:
|
| 121 |
+
rma = await RMAService.update_rma(rma_id, payload)
|
| 122 |
+
|
| 123 |
+
# Using merchant_id from result if available
|
| 124 |
+
merchant_id = rma.get("merchant_id")
|
| 125 |
+
|
| 126 |
+
logger.info(
|
| 127 |
+
f"RMA {rma_id} updated",
|
| 128 |
+
extra={
|
| 129 |
+
"operation": "update_rma",
|
| 130 |
+
"rma_id": rma_id,
|
| 131 |
+
"merchant_id": merchant_id
|
| 132 |
+
}
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
return RMAResponse(**rma)
|
| 136 |
+
except HTTPException:
|
| 137 |
+
raise
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(
|
| 140 |
+
f"Error updating RMA {rma_id}",
|
| 141 |
+
extra={
|
| 142 |
+
"operation": "update_rma",
|
| 143 |
+
"rma_id": rma_id,
|
| 144 |
+
"error": str(e)
|
| 145 |
+
},
|
| 146 |
+
exc_info=True
|
| 147 |
+
)
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 150 |
+
detail=f"Error updating RMA {rma_id}"
|
| 151 |
+
)
|
| 152 |
|
| 153 |
|
| 154 |
@router.post(
|
|
|
|
| 165 |
- **approved_action**: Final approved action (may differ from requested)
|
| 166 |
- **return_window_days**: Days allowed for return
|
| 167 |
"""
|
| 168 |
+
try:
|
| 169 |
+
rma = await RMAService.approve_rma(rma_id, payload)
|
| 170 |
+
|
| 171 |
+
action = "approved" if payload.approved else "rejected"
|
| 172 |
+
merchant_id = rma.get("merchant_id")
|
| 173 |
+
|
| 174 |
+
logger.info(
|
| 175 |
+
f"RMA {rma_id} {action}",
|
| 176 |
+
extra={
|
| 177 |
+
"operation": "approve_rma",
|
| 178 |
+
"rma_id": rma_id,
|
| 179 |
+
"action": action,
|
| 180 |
+
"merchant_id": merchant_id,
|
| 181 |
+
"user_id": payload.approved_by
|
| 182 |
+
}
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
return RMAResponse(**rma)
|
| 186 |
+
except HTTPException:
|
| 187 |
+
raise
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(
|
| 190 |
+
f"Error approving RMA {rma_id}",
|
| 191 |
+
extra={
|
| 192 |
+
"operation": "approve_rma",
|
| 193 |
+
"rma_id": rma_id,
|
| 194 |
+
"error": str(e),
|
| 195 |
+
"user_id": payload.approved_by
|
| 196 |
+
},
|
| 197 |
+
exc_info=True
|
| 198 |
+
)
|
| 199 |
+
raise HTTPException(
|
| 200 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 201 |
+
detail=f"Error approving RMA {rma_id}"
|
| 202 |
+
)
|
| 203 |
|
| 204 |
|
| 205 |
@router.post(
|
|
|
|
| 216 |
- **pickup_address**: Address for pickup
|
| 217 |
- **pickup_contact**: Contact person details
|
| 218 |
"""
|
| 219 |
+
try:
|
| 220 |
+
rma = await RMAService.schedule_pickup(rma_id, payload)
|
| 221 |
+
|
| 222 |
+
merchant_id = rma.get("merchant_id")
|
| 223 |
+
|
| 224 |
+
logger.info(
|
| 225 |
+
f"Pickup scheduled for RMA {rma_id}",
|
| 226 |
+
extra={
|
| 227 |
+
"operation": "schedule_pickup",
|
| 228 |
+
"rma_id": rma_id,
|
| 229 |
+
"merchant_id": merchant_id,
|
| 230 |
+
"user_id": payload.scheduled_by
|
| 231 |
+
}
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
return RMAResponse(**rma)
|
| 235 |
+
except HTTPException:
|
| 236 |
+
raise
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger.error(
|
| 239 |
+
f"Error scheduling pickup for RMA {rma_id}",
|
| 240 |
+
extra={
|
| 241 |
+
"operation": "schedule_pickup",
|
| 242 |
+
"rma_id": rma_id,
|
| 243 |
+
"error": str(e),
|
| 244 |
+
"user_id": payload.scheduled_by
|
| 245 |
+
},
|
| 246 |
+
exc_info=True
|
| 247 |
+
)
|
| 248 |
+
raise HTTPException(
|
| 249 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 250 |
+
detail=f"Error scheduling pickup for RMA {rma_id}"
|
| 251 |
+
)
|
| 252 |
|
| 253 |
|
| 254 |
@router.post(
|
|
|
|
| 271 |
- Issue refund/credit note based on inspection
|
| 272 |
- Close RMA if fully processed
|
| 273 |
"""
|
| 274 |
+
try:
|
| 275 |
+
rma = await RMAService.inspect_rma(rma_id, payload)
|
| 276 |
+
|
| 277 |
+
merchant_id = rma.get("merchant_id")
|
| 278 |
+
|
| 279 |
+
logger.info(
|
| 280 |
+
f"RMA {rma_id} inspected",
|
| 281 |
+
extra={
|
| 282 |
+
"operation": "inspect_rma",
|
| 283 |
+
"rma_id": rma_id,
|
| 284 |
+
"result": payload.inspection_result.value,
|
| 285 |
+
"merchant_id": merchant_id,
|
| 286 |
+
"user_id": payload.inspected_by
|
| 287 |
+
}
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
return RMAResponse(**rma)
|
| 291 |
+
except HTTPException:
|
| 292 |
+
raise
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logger.error(
|
| 295 |
+
f"Error inspecting RMA {rma_id}",
|
| 296 |
+
extra={
|
| 297 |
+
"operation": "inspect_rma",
|
| 298 |
+
"rma_id": rma_id,
|
| 299 |
+
"error": str(e),
|
| 300 |
+
"user_id": payload.inspected_by
|
| 301 |
+
},
|
| 302 |
+
exc_info=True
|
| 303 |
+
)
|
| 304 |
+
raise HTTPException(
|
| 305 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 306 |
+
detail=f"Error inspecting RMA {rma_id}"
|
| 307 |
+
)
|
| 308 |
|
| 309 |
|
| 310 |
@router.get(
|
|
|
|
| 322 |
"""
|
| 323 |
List RMAs with optional filters.
|
| 324 |
"""
|
| 325 |
+
try:
|
| 326 |
+
rmas = await RMAService.list_rmas(
|
| 327 |
+
merchant_id=merchant_id,
|
| 328 |
+
requestor_id=requestor_id,
|
| 329 |
+
status=status.value if status else None,
|
| 330 |
+
skip=skip,
|
| 331 |
+
limit=limit
|
| 332 |
+
)
|
| 333 |
+
return [RMAListResponse(**rma) for rma in rmas]
|
| 334 |
+
except HTTPException:
|
| 335 |
+
raise
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(
|
| 338 |
+
"Error listing RMAs",
|
| 339 |
+
extra={
|
| 340 |
+
"operation": "list_rmas",
|
| 341 |
+
"error": str(e),
|
| 342 |
+
"merchant_id": merchant_id
|
| 343 |
+
},
|
| 344 |
+
exc_info=True
|
| 345 |
+
)
|
| 346 |
+
raise HTTPException(
|
| 347 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 348 |
+
detail="Error listing RMAs"
|
| 349 |
+
)
|
| 350 |
|
| 351 |
|
| 352 |
@router.delete(
|
|
|
|
| 362 |
Cancel an RMA.
|
| 363 |
Only allowed for requested or approved RMAs.
|
| 364 |
"""
|
| 365 |
+
try:
|
| 366 |
+
result = await RMAService.cancel_rma(rma_id, cancelled_by, reason)
|
| 367 |
+
|
| 368 |
+
merchant_id = result.get("merchant_id")
|
| 369 |
+
|
| 370 |
+
logger.info(
|
| 371 |
+
f"RMA {rma_id} cancelled",
|
| 372 |
+
extra={
|
| 373 |
+
"operation": "cancel_rma",
|
| 374 |
+
"rma_id": rma_id,
|
| 375 |
+
"merchant_id": merchant_id,
|
| 376 |
+
"user_id": cancelled_by,
|
| 377 |
+
"reason": reason
|
| 378 |
+
}
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
return {"message": f"RMA {rma_id} cancelled successfully"}
|
| 382 |
+
except HTTPException:
|
| 383 |
+
raise
|
| 384 |
+
except Exception as e:
|
| 385 |
+
logger.error(
|
| 386 |
+
f"Error cancelling RMA {rma_id}",
|
| 387 |
+
extra={
|
| 388 |
+
"operation": "cancel_rma",
|
| 389 |
+
"rma_id": rma_id,
|
| 390 |
+
"error": str(e),
|
| 391 |
+
"user_id": cancelled_by
|
| 392 |
+
},
|
| 393 |
+
exc_info=True
|
| 394 |
+
)
|
| 395 |
+
raise HTTPException(
|
| 396 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 397 |
+
detail=f"Error cancelling RMA {rma_id}"
|
| 398 |
+
)
|
app/sales/returns/services/service.py
CHANGED
|
@@ -5,8 +5,7 @@ from datetime import datetime
|
|
| 5 |
from typing import Optional, List, Dict, Any
|
| 6 |
from decimal import Decimal
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
-
|
| 9 |
-
import logging
|
| 10 |
import secrets
|
| 11 |
|
| 12 |
from app.nosql import get_database
|
|
@@ -26,7 +25,7 @@ from app.sales.returns.schemas.schema import (
|
|
| 26 |
)
|
| 27 |
|
| 28 |
# logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
|
| 29 |
-
logger =
|
| 30 |
|
| 31 |
|
| 32 |
def generate_rma_number(merchant_code: str) -> str:
|
|
@@ -49,83 +48,101 @@ class RMAService:
|
|
| 49 |
"""Create a new RMA"""
|
| 50 |
db = get_database()
|
| 51 |
|
| 52 |
-
# Validate related order exists
|
| 53 |
-
order = await db[SCM_SALES_ORDERS_COLLECTION].find_one({"sales_order_id": payload.related_order_id})
|
| 54 |
-
if not order:
|
| 55 |
-
raise HTTPException(
|
| 56 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 57 |
-
detail=f"Order {payload.related_order_id} not found"
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
# Validate items are in the order
|
| 61 |
-
order_skus = {item["sku"] for item in order.get("items", [])}
|
| 62 |
-
rma_skus = {item.sku for item in payload.items}
|
| 63 |
-
|
| 64 |
-
if not rma_skus.issubset(order_skus):
|
| 65 |
-
invalid_skus = rma_skus - order_skus
|
| 66 |
-
raise HTTPException(
|
| 67 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 68 |
-
detail=f"Invalid SKUs not in order: {invalid_skus}"
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
# Generate RMA ID and number
|
| 72 |
-
rma_id = generate_rma_id()
|
| 73 |
-
merchant_code = payload.merchant_id.split("_")[0] if "_" in payload.merchant_id else "MER"
|
| 74 |
-
rma_number = payload.rma_number or generate_rma_number(merchant_code)
|
| 75 |
-
|
| 76 |
-
# Create RMA document
|
| 77 |
-
now = datetime.utcnow()
|
| 78 |
-
items_dict = [item.dict() for item in payload.items]
|
| 79 |
-
|
| 80 |
-
rma_doc = {
|
| 81 |
-
"rma_id": rma_id,
|
| 82 |
-
"rma_number": rma_number,
|
| 83 |
-
"related_order_id": payload.related_order_id,
|
| 84 |
-
"related_order_type": payload.related_order_type,
|
| 85 |
-
"related_order_number": order.get("order_number"),
|
| 86 |
-
"requestor_id": payload.requestor_id,
|
| 87 |
-
"requestor_type": payload.requestor_type,
|
| 88 |
-
"requestor_name": None, # TODO: Fetch from customer/merchant service
|
| 89 |
-
"merchant_id": payload.merchant_id,
|
| 90 |
-
"status": RMAStatus.REQUESTED.value,
|
| 91 |
-
"items": items_dict,
|
| 92 |
-
"requested_action": payload.requested_action.value,
|
| 93 |
-
"approved_action": None,
|
| 94 |
-
"return_reason": payload.return_reason,
|
| 95 |
-
"return_address": payload.return_address,
|
| 96 |
-
"pickup_required": payload.pickup_required,
|
| 97 |
-
"pickup_scheduled": False,
|
| 98 |
-
"pickup_details": None,
|
| 99 |
-
"shipment_id": None,
|
| 100 |
-
"tracking_number": None,
|
| 101 |
-
"inspection_result": None,
|
| 102 |
-
"inspection_notes": None,
|
| 103 |
-
"refund_amount": None,
|
| 104 |
-
"store_credit_amount": None,
|
| 105 |
-
"credit_note_id": None,
|
| 106 |
-
"replacement_order_id": None,
|
| 107 |
-
"notes": payload.notes,
|
| 108 |
-
"internal_notes": None,
|
| 109 |
-
"created_by": payload.created_by,
|
| 110 |
-
"created_at": now.isoformat(),
|
| 111 |
-
"updated_at": now.isoformat(),
|
| 112 |
-
"approved_at": None,
|
| 113 |
-
"approved_by": None,
|
| 114 |
-
"rejected_at": None,
|
| 115 |
-
"rejected_by": None,
|
| 116 |
-
"rejection_reason": None,
|
| 117 |
-
"inspected_at": None,
|
| 118 |
-
"inspected_by": None,
|
| 119 |
-
"closed_at": None,
|
| 120 |
-
"metadata": {}
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
await db[SCM_RMA_COLLECTION].insert_one(rma_doc)
|
| 125 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
return rma_doc
|
|
|
|
|
|
|
| 127 |
except Exception as e:
|
| 128 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
raise HTTPException(
|
| 130 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 131 |
detail="Error creating RMA"
|
|
@@ -134,152 +151,245 @@ class RMAService:
|
|
| 134 |
@staticmethod
|
| 135 |
async def get_rma(rma_id: str) -> Dict[str, Any]:
|
| 136 |
"""Get RMA by ID"""
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
raise HTTPException(
|
| 142 |
-
status_code=status.
|
| 143 |
-
detail=f"RMA {rma_id}
|
| 144 |
)
|
| 145 |
-
|
| 146 |
-
return rma
|
| 147 |
|
| 148 |
@staticmethod
|
| 149 |
async def approve_rma(rma_id: str, payload: RMAApproveRequest) -> Dict[str, Any]:
|
| 150 |
"""Approve or reject RMA"""
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
raise HTTPException(
|
| 157 |
-
status_code=status.
|
| 158 |
-
detail=f"
|
| 159 |
)
|
| 160 |
-
|
| 161 |
-
now = datetime.utcnow()
|
| 162 |
-
update_data = {
|
| 163 |
-
"updated_at": now.isoformat()
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
if payload.approved:
|
| 167 |
-
update_data["status"] = RMAStatus.APPROVED.value
|
| 168 |
-
update_data["approved_at"] = now.isoformat()
|
| 169 |
-
update_data["approved_by"] = payload.approved_by
|
| 170 |
-
update_data["approved_action"] = payload.approved_action.value if payload.approved_action else rma["requested_action"]
|
| 171 |
-
|
| 172 |
-
# Update items if partial approval
|
| 173 |
-
if payload.items:
|
| 174 |
-
items_dict = [item.dict() for item in payload.items]
|
| 175 |
-
update_data["items"] = items_dict
|
| 176 |
-
else:
|
| 177 |
-
update_data["status"] = RMAStatus.REJECTED.value
|
| 178 |
-
update_data["rejected_at"] = now.isoformat()
|
| 179 |
-
update_data["rejected_by"] = payload.approved_by
|
| 180 |
-
update_data["rejection_reason"] = payload.rejection_reason
|
| 181 |
-
|
| 182 |
-
await db[SCM_RMA_COLLECTION].update_one(
|
| 183 |
-
{"rma_id": rma_id},
|
| 184 |
-
{"$set": update_data}
|
| 185 |
-
)
|
| 186 |
-
|
| 187 |
-
logger.info(f"{'Approved' if payload.approved else 'Rejected'} RMA {rma_id}", extra={"rma_id": rma_id})
|
| 188 |
-
|
| 189 |
-
return await RMAService.get_rma(rma_id)
|
| 190 |
|
| 191 |
@staticmethod
|
| 192 |
async def schedule_pickup(rma_id: str, payload: RMAPickupRequest) -> Dict[str, Any]:
|
| 193 |
"""Schedule pickup for RMA"""
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
raise HTTPException(
|
| 200 |
-
status_code=status.
|
| 201 |
-
detail=f"
|
| 202 |
)
|
| 203 |
-
|
| 204 |
-
pickup_details = {
|
| 205 |
-
"carrier": payload.carrier,
|
| 206 |
-
"pickup_date": payload.pickup_date.isoformat(),
|
| 207 |
-
"pickup_address": payload.pickup_address,
|
| 208 |
-
"pickup_contact_name": payload.pickup_contact_name,
|
| 209 |
-
"pickup_contact_phone": payload.pickup_contact_phone,
|
| 210 |
-
"special_instructions": payload.special_instructions,
|
| 211 |
-
"scheduled_by": payload.scheduled_by,
|
| 212 |
-
"scheduled_at": datetime.utcnow().isoformat()
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
update_data = {
|
| 216 |
-
"status": RMAStatus.PICKED.value,
|
| 217 |
-
"pickup_scheduled": True,
|
| 218 |
-
"pickup_details": pickup_details,
|
| 219 |
-
"updated_at": datetime.utcnow().isoformat()
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
await db[SCM_RMA_COLLECTION].update_one(
|
| 223 |
-
{"rma_id": rma_id},
|
| 224 |
-
{"$set": update_data}
|
| 225 |
-
)
|
| 226 |
-
|
| 227 |
-
logger.info(f"Scheduled pickup for RMA {rma_id}", extra={"rma_id": rma_id, "carrier": payload.carrier})
|
| 228 |
-
|
| 229 |
-
return await RMAService.get_rma(rma_id)
|
| 230 |
|
| 231 |
@staticmethod
|
| 232 |
async def inspect_rma(rma_id: str, payload: RMAInspectRequest) -> Dict[str, Any]:
|
| 233 |
"""Perform inspection on returned items"""
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
rma = await RMAService.get_rma(rma_id)
|
| 237 |
-
|
| 238 |
-
if rma["status"] not in [RMAStatus.PICKED.value, RMAStatus.IN_TRANSIT.value, RMAStatus.RECEIVED.value]:
|
| 239 |
-
raise HTTPException(
|
| 240 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 241 |
-
detail=f"Cannot inspect RMA with status {rma['status']}"
|
| 242 |
-
)
|
| 243 |
-
|
| 244 |
-
now = datetime.utcnow()
|
| 245 |
-
update_data = {
|
| 246 |
-
"status": RMAStatus.INSPECTED.value,
|
| 247 |
-
"inspection_result": payload.inspection_result.value,
|
| 248 |
-
"inspection_notes": payload.inspection_notes,
|
| 249 |
-
"inspected_at": payload.inspected_at.isoformat(),
|
| 250 |
-
"inspected_by": payload.inspected_by,
|
| 251 |
-
"updated_at": now.isoformat()
|
| 252 |
-
}
|
| 253 |
-
|
| 254 |
-
# Process based on inspection result
|
| 255 |
-
if payload.inspection_result.value in ["approved", "partial_approved"]:
|
| 256 |
-
if payload.refund_amount:
|
| 257 |
-
update_data["refund_amount"] = float(payload.refund_amount)
|
| 258 |
|
| 259 |
-
|
| 260 |
-
update_data["store_credit_amount"] = float(payload.store_credit_amount)
|
| 261 |
|
| 262 |
-
if
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
-
#
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
@staticmethod
|
| 285 |
async def _update_inventory_for_return(rma: Dict[str, Any], inspected_items: List[Dict[str, Any]]):
|
|
@@ -312,37 +422,60 @@ class RMAService:
|
|
| 312 |
@staticmethod
|
| 313 |
async def update_rma(rma_id: str, payload: RMAUpdate) -> Dict[str, Any]:
|
| 314 |
"""Update RMA"""
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
)
|
| 324 |
-
|
| 325 |
-
update_data = payload.dict(exclude_unset=True)
|
| 326 |
-
if not update_data:
|
| 327 |
raise HTTPException(
|
| 328 |
-
status_code=status.
|
| 329 |
-
detail="
|
| 330 |
)
|
| 331 |
-
|
| 332 |
-
if "items" in update_data:
|
| 333 |
-
items_dict = [item.dict() for item in update_data["items"]]
|
| 334 |
-
update_data["items"] = items_dict
|
| 335 |
-
|
| 336 |
-
update_data["updated_at"] = datetime.utcnow().isoformat()
|
| 337 |
-
|
| 338 |
-
await db[SCM_RMA_COLLECTION].update_one(
|
| 339 |
-
{"rma_id": rma_id},
|
| 340 |
-
{"$set": update_data}
|
| 341 |
-
)
|
| 342 |
-
|
| 343 |
-
logger.info(f"Updated RMA {rma_id}", extra={"rma_id": rma_id})
|
| 344 |
-
|
| 345 |
-
return await RMAService.get_rma(rma_id)
|
| 346 |
|
| 347 |
@staticmethod
|
| 348 |
async def list_rmas(
|
|
@@ -368,7 +501,15 @@ class RMAService:
|
|
| 368 |
rmas = await cursor.to_list(length=limit)
|
| 369 |
return rmas
|
| 370 |
except Exception as e:
|
| 371 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
raise HTTPException(
|
| 373 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 374 |
detail="Error listing RMAs"
|
|
@@ -377,30 +518,55 @@ class RMAService:
|
|
| 377 |
@staticmethod
|
| 378 |
async def cancel_rma(rma_id: str, cancelled_by: str, reason: str) -> Dict[str, Any]:
|
| 379 |
"""Cancel RMA"""
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
raise HTTPException(
|
| 386 |
-
status_code=status.
|
| 387 |
-
detail=f"
|
| 388 |
)
|
| 389 |
-
|
| 390 |
-
now = datetime.utcnow()
|
| 391 |
-
update_data = {
|
| 392 |
-
"status": RMAStatus.CANCELLED.value,
|
| 393 |
-
"rejection_reason": reason,
|
| 394 |
-
"rejected_by": cancelled_by,
|
| 395 |
-
"rejected_at": now.isoformat(),
|
| 396 |
-
"updated_at": now.isoformat()
|
| 397 |
-
}
|
| 398 |
-
|
| 399 |
-
await db[SCM_RMA_COLLECTION].update_one(
|
| 400 |
-
{"rma_id": rma_id},
|
| 401 |
-
{"$set": update_data}
|
| 402 |
-
)
|
| 403 |
-
|
| 404 |
-
logger.info(f"Cancelled RMA {rma_id}", extra={"rma_id": rma_id})
|
| 405 |
-
|
| 406 |
-
return await RMAService.get_rma(rma_id)
|
|
|
|
| 5 |
from typing import Optional, List, Dict, Any
|
| 6 |
from decimal import Decimal
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
+
from app.core.logging import get_logger
|
|
|
|
| 9 |
import secrets
|
| 10 |
|
| 11 |
from app.nosql import get_database
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
# logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
|
| 28 |
+
logger = get_logger(__name__)
|
| 29 |
|
| 30 |
|
| 31 |
def generate_rma_number(merchant_code: str) -> str:
|
|
|
|
| 48 |
"""Create a new RMA"""
|
| 49 |
db = get_database()
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
try:
|
| 52 |
+
# Validate related order exists
|
| 53 |
+
order = await db[SCM_SALES_ORDERS_COLLECTION].find_one({"sales_order_id": payload.related_order_id})
|
| 54 |
+
if not order:
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 57 |
+
detail=f"Order {payload.related_order_id} not found"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Validate items are in the order
|
| 61 |
+
order_skus = {item["sku"] for item in order.get("items", [])}
|
| 62 |
+
rma_skus = {item.sku for item in payload.items}
|
| 63 |
+
|
| 64 |
+
if not rma_skus.issubset(order_skus):
|
| 65 |
+
invalid_skus = rma_skus - order_skus
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 68 |
+
detail=f"Invalid SKUs not in order: {invalid_skus}"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Generate RMA ID and number
|
| 72 |
+
rma_id = generate_rma_id()
|
| 73 |
+
merchant_code = payload.merchant_id.split("_")[0] if "_" in payload.merchant_id else "MER"
|
| 74 |
+
rma_number = payload.rma_number or generate_rma_number(merchant_code)
|
| 75 |
+
|
| 76 |
+
# Create RMA document
|
| 77 |
+
now = datetime.utcnow()
|
| 78 |
+
items_dict = [item.dict() for item in payload.items]
|
| 79 |
+
|
| 80 |
+
rma_doc = {
|
| 81 |
+
"rma_id": rma_id,
|
| 82 |
+
"rma_number": rma_number,
|
| 83 |
+
"related_order_id": payload.related_order_id,
|
| 84 |
+
"related_order_type": payload.related_order_type,
|
| 85 |
+
"related_order_number": order.get("order_number"),
|
| 86 |
+
"requestor_id": payload.requestor_id,
|
| 87 |
+
"requestor_type": payload.requestor_type,
|
| 88 |
+
"requestor_name": None, # TODO: Fetch from customer/merchant service
|
| 89 |
+
"merchant_id": payload.merchant_id,
|
| 90 |
+
"status": RMAStatus.REQUESTED.value,
|
| 91 |
+
"items": items_dict,
|
| 92 |
+
"requested_action": payload.requested_action.value,
|
| 93 |
+
"approved_action": None,
|
| 94 |
+
"return_reason": payload.return_reason,
|
| 95 |
+
"return_address": payload.return_address,
|
| 96 |
+
"pickup_required": payload.pickup_required,
|
| 97 |
+
"pickup_scheduled": False,
|
| 98 |
+
"pickup_details": None,
|
| 99 |
+
"shipment_id": None,
|
| 100 |
+
"tracking_number": None,
|
| 101 |
+
"inspection_result": None,
|
| 102 |
+
"inspection_notes": None,
|
| 103 |
+
"refund_amount": None,
|
| 104 |
+
"store_credit_amount": None,
|
| 105 |
+
"credit_note_id": None,
|
| 106 |
+
"replacement_order_id": None,
|
| 107 |
+
"notes": payload.notes,
|
| 108 |
+
"internal_notes": None,
|
| 109 |
+
"created_by": payload.created_by,
|
| 110 |
+
"created_at": now.isoformat(),
|
| 111 |
+
"updated_at": now.isoformat(),
|
| 112 |
+
"approved_at": None,
|
| 113 |
+
"approved_by": None,
|
| 114 |
+
"rejected_at": None,
|
| 115 |
+
"rejected_by": None,
|
| 116 |
+
"rejection_reason": None,
|
| 117 |
+
"inspected_at": None,
|
| 118 |
+
"inspected_by": None,
|
| 119 |
+
"closed_at": None,
|
| 120 |
+
"metadata": {}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
await db[SCM_RMA_COLLECTION].insert_one(rma_doc)
|
| 124 |
+
logger.info(
|
| 125 |
+
f"Created RMA {rma_id}",
|
| 126 |
+
extra={
|
| 127 |
+
"operation": "create_rma",
|
| 128 |
+
"rma_id": rma_id,
|
| 129 |
+
"order_id": payload.related_order_id,
|
| 130 |
+
"merchant_id": payload.merchant_id
|
| 131 |
+
}
|
| 132 |
+
)
|
| 133 |
return rma_doc
|
| 134 |
+
except HTTPException:
|
| 135 |
+
raise
|
| 136 |
except Exception as e:
|
| 137 |
+
logger.error(
|
| 138 |
+
"Error creating RMA",
|
| 139 |
+
extra={
|
| 140 |
+
"operation": "create_rma",
|
| 141 |
+
"error": str(e),
|
| 142 |
+
"merchant_id": payload.merchant_id
|
| 143 |
+
},
|
| 144 |
+
exc_info=True
|
| 145 |
+
)
|
| 146 |
raise HTTPException(
|
| 147 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 148 |
detail="Error creating RMA"
|
|
|
|
| 151 |
@staticmethod
|
| 152 |
async def get_rma(rma_id: str) -> Dict[str, Any]:
|
| 153 |
"""Get RMA by ID"""
|
| 154 |
+
try:
|
| 155 |
+
db = get_database()
|
| 156 |
+
|
| 157 |
+
rma = await db[SCM_RMA_COLLECTION].find_one({"rma_id": rma_id})
|
| 158 |
+
if not rma:
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 161 |
+
detail=f"RMA {rma_id} not found"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
return rma
|
| 165 |
+
except HTTPException:
|
| 166 |
+
raise
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(
|
| 169 |
+
f"Error getting RMA {rma_id}",
|
| 170 |
+
extra={
|
| 171 |
+
"operation": "get_rma",
|
| 172 |
+
"rma_id": rma_id,
|
| 173 |
+
"error": str(e)
|
| 174 |
+
},
|
| 175 |
+
exc_info=True
|
| 176 |
+
)
|
| 177 |
raise HTTPException(
|
| 178 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 179 |
+
detail=f"Error getting RMA {rma_id}"
|
| 180 |
)
|
|
|
|
|
|
|
| 181 |
|
| 182 |
@staticmethod
|
| 183 |
async def approve_rma(rma_id: str, payload: RMAApproveRequest) -> Dict[str, Any]:
|
| 184 |
"""Approve or reject RMA"""
|
| 185 |
+
try:
|
| 186 |
+
db = get_database()
|
| 187 |
+
|
| 188 |
+
rma = await RMAService.get_rma(rma_id)
|
| 189 |
+
|
| 190 |
+
if rma["status"] != RMAStatus.REQUESTED.value:
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 193 |
+
detail=f"Cannot approve RMA with status {rma['status']}"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
now = datetime.utcnow()
|
| 197 |
+
update_data = {
|
| 198 |
+
"updated_at": now.isoformat()
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
if payload.approved:
|
| 202 |
+
update_data["status"] = RMAStatus.APPROVED.value
|
| 203 |
+
update_data["approved_at"] = now.isoformat()
|
| 204 |
+
update_data["approved_by"] = payload.approved_by
|
| 205 |
+
update_data["approved_action"] = payload.approved_action.value if payload.approved_action else rma["requested_action"]
|
| 206 |
+
|
| 207 |
+
# Update items if partial approval
|
| 208 |
+
if payload.items:
|
| 209 |
+
items_dict = [item.dict() for item in payload.items]
|
| 210 |
+
update_data["items"] = items_dict
|
| 211 |
+
else:
|
| 212 |
+
update_data["status"] = RMAStatus.REJECTED.value
|
| 213 |
+
update_data["rejected_at"] = now.isoformat()
|
| 214 |
+
update_data["rejected_by"] = payload.approved_by
|
| 215 |
+
update_data["rejection_reason"] = payload.rejection_reason
|
| 216 |
+
|
| 217 |
+
await db[SCM_RMA_COLLECTION].update_one(
|
| 218 |
+
{"rma_id": rma_id},
|
| 219 |
+
{"$set": update_data}
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
action = "approve" if payload.approved else "reject"
|
| 223 |
+
logger.info(
|
| 224 |
+
f"{'Approved' if payload.approved else 'Rejected'} RMA {rma_id}",
|
| 225 |
+
extra={
|
| 226 |
+
"operation": "approve_rma",
|
| 227 |
+
"rma_id": rma_id,
|
| 228 |
+
"action": action,
|
| 229 |
+
"user_id": payload.approved_by
|
| 230 |
+
}
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
return await RMAService.get_rma(rma_id)
|
| 234 |
+
except HTTPException:
|
| 235 |
+
raise
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.error(
|
| 238 |
+
f"Error approving RMA {rma_id}",
|
| 239 |
+
extra={
|
| 240 |
+
"operation": "approve_rma",
|
| 241 |
+
"rma_id": rma_id,
|
| 242 |
+
"error": str(e)
|
| 243 |
+
},
|
| 244 |
+
exc_info=True
|
| 245 |
+
)
|
| 246 |
raise HTTPException(
|
| 247 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 248 |
+
detail=f"Error approving RMA {rma_id}"
|
| 249 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
@staticmethod
|
| 252 |
async def schedule_pickup(rma_id: str, payload: RMAPickupRequest) -> Dict[str, Any]:
|
| 253 |
"""Schedule pickup for RMA"""
|
| 254 |
+
try:
|
| 255 |
+
db = get_database()
|
| 256 |
+
|
| 257 |
+
rma = await RMAService.get_rma(rma_id)
|
| 258 |
+
|
| 259 |
+
if rma["status"] != RMAStatus.APPROVED.value:
|
| 260 |
+
raise HTTPException(
|
| 261 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 262 |
+
detail=f"Cannot schedule pickup for RMA with status {rma['status']}"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
pickup_details = {
|
| 266 |
+
"carrier": payload.carrier,
|
| 267 |
+
"pickup_date": payload.pickup_date.isoformat(),
|
| 268 |
+
"pickup_address": payload.pickup_address,
|
| 269 |
+
"pickup_contact_name": payload.pickup_contact_name,
|
| 270 |
+
"pickup_contact_phone": payload.pickup_contact_phone,
|
| 271 |
+
"special_instructions": payload.special_instructions,
|
| 272 |
+
"scheduled_by": payload.scheduled_by,
|
| 273 |
+
"scheduled_at": datetime.utcnow().isoformat()
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
update_data = {
|
| 277 |
+
"status": RMAStatus.PICKED.value,
|
| 278 |
+
"pickup_scheduled": True,
|
| 279 |
+
"pickup_details": pickup_details,
|
| 280 |
+
"updated_at": datetime.utcnow().isoformat()
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
await db[SCM_RMA_COLLECTION].update_one(
|
| 284 |
+
{"rma_id": rma_id},
|
| 285 |
+
{"$set": update_data}
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
logger.info(
|
| 289 |
+
f"Scheduled pickup for RMA {rma_id}",
|
| 290 |
+
extra={
|
| 291 |
+
"operation": "schedule_pickup",
|
| 292 |
+
"rma_id": rma_id,
|
| 293 |
+
"carrier": payload.carrier,
|
| 294 |
+
"user_id": payload.scheduled_by
|
| 295 |
+
}
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
return await RMAService.get_rma(rma_id)
|
| 299 |
+
except HTTPException:
|
| 300 |
+
raise
|
| 301 |
+
except Exception as e:
|
| 302 |
+
logger.error(
|
| 303 |
+
f"Error scheduling pickup for RMA {rma_id}",
|
| 304 |
+
extra={
|
| 305 |
+
"operation": "schedule_pickup",
|
| 306 |
+
"rma_id": rma_id,
|
| 307 |
+
"error": str(e)
|
| 308 |
+
},
|
| 309 |
+
exc_info=True
|
| 310 |
+
)
|
| 311 |
raise HTTPException(
|
| 312 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 313 |
+
detail=f"Error scheduling pickup for RMA {rma_id}"
|
| 314 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
@staticmethod
|
| 317 |
async def inspect_rma(rma_id: str, payload: RMAInspectRequest) -> Dict[str, Any]:
|
| 318 |
"""Perform inspection on returned items"""
|
| 319 |
+
try:
|
| 320 |
+
db = get_database()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
+
rma = await RMAService.get_rma(rma_id)
|
|
|
|
| 323 |
|
| 324 |
+
if rma["status"] not in [RMAStatus.PICKED.value, RMAStatus.IN_TRANSIT.value, RMAStatus.RECEIVED.value]:
|
| 325 |
+
raise HTTPException(
|
| 326 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 327 |
+
detail=f"Cannot inspect RMA with status {rma['status']}"
|
| 328 |
+
)
|
| 329 |
|
| 330 |
+
now = datetime.utcnow()
|
| 331 |
+
update_data = {
|
| 332 |
+
"status": RMAStatus.INSPECTED.value,
|
| 333 |
+
"inspection_result": payload.inspection_result.value,
|
| 334 |
+
"inspection_notes": payload.inspection_notes,
|
| 335 |
+
"inspected_at": payload.inspected_at.isoformat(),
|
| 336 |
+
"inspected_by": payload.inspected_by,
|
| 337 |
+
"updated_at": now.isoformat()
|
| 338 |
+
}
|
| 339 |
|
| 340 |
+
# Process based on inspection result
|
| 341 |
+
if payload.inspection_result.value in ["approved", "partial_approved"]:
|
| 342 |
+
if payload.refund_amount:
|
| 343 |
+
update_data["refund_amount"] = float(payload.refund_amount)
|
| 344 |
+
|
| 345 |
+
if payload.store_credit_amount:
|
| 346 |
+
update_data["store_credit_amount"] = float(payload.store_credit_amount)
|
| 347 |
+
|
| 348 |
+
if payload.credit_note_id:
|
| 349 |
+
update_data["credit_note_id"] = payload.credit_note_id
|
| 350 |
+
|
| 351 |
+
if payload.replacement_order_id:
|
| 352 |
+
update_data["replacement_order_id"] = payload.replacement_order_id
|
| 353 |
+
|
| 354 |
+
# Update inventory ledger for returned items
|
| 355 |
+
await RMAService._update_inventory_for_return(rma, payload.items)
|
| 356 |
+
|
| 357 |
+
# Close RMA if fully processed
|
| 358 |
+
update_data["status"] = RMAStatus.CLOSED.value
|
| 359 |
+
update_data["closed_at"] = now.isoformat()
|
| 360 |
|
| 361 |
+
await db[SCM_RMA_COLLECTION].update_one(
|
| 362 |
+
{"rma_id": rma_id},
|
| 363 |
+
{"$set": update_data}
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
logger.info(
|
| 367 |
+
f"Inspected RMA {rma_id}",
|
| 368 |
+
extra={
|
| 369 |
+
"operation": "inspect_rma",
|
| 370 |
+
"rma_id": rma_id,
|
| 371 |
+
"result": payload.inspection_result.value,
|
| 372 |
+
"user_id": payload.inspected_by
|
| 373 |
+
}
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
return await RMAService.get_rma(rma_id)
|
| 377 |
+
except HTTPException:
|
| 378 |
+
raise
|
| 379 |
+
except Exception as e:
|
| 380 |
+
logger.error(
|
| 381 |
+
f"Error inspecting RMA {rma_id}",
|
| 382 |
+
extra={
|
| 383 |
+
"operation": "inspect_rma",
|
| 384 |
+
"rma_id": rma_id,
|
| 385 |
+
"error": str(e)
|
| 386 |
+
},
|
| 387 |
+
exc_info=True
|
| 388 |
+
)
|
| 389 |
+
raise HTTPException(
|
| 390 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 391 |
+
detail=f"Error inspecting RMA {rma_id}"
|
| 392 |
+
)
|
| 393 |
|
| 394 |
@staticmethod
|
| 395 |
async def _update_inventory_for_return(rma: Dict[str, Any], inspected_items: List[Dict[str, Any]]):
|
|
|
|
| 422 |
@staticmethod
|
| 423 |
async def update_rma(rma_id: str, payload: RMAUpdate) -> Dict[str, Any]:
|
| 424 |
"""Update RMA"""
|
| 425 |
+
try:
|
| 426 |
+
db = get_database()
|
| 427 |
+
|
| 428 |
+
rma = await RMAService.get_rma(rma_id)
|
| 429 |
+
|
| 430 |
+
if rma["status"] in [RMAStatus.CLOSED.value, RMAStatus.CANCELLED.value]:
|
| 431 |
+
raise HTTPException(
|
| 432 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 433 |
+
detail=f"Cannot update RMA with status {rma['status']}"
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
update_data = payload.dict(exclude_unset=True)
|
| 437 |
+
if not update_data:
|
| 438 |
+
raise HTTPException(
|
| 439 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 440 |
+
detail="No update data provided"
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
if "items" in update_data:
|
| 444 |
+
items_dict = [item.dict() for item in update_data["items"]]
|
| 445 |
+
update_data["items"] = items_dict
|
| 446 |
+
|
| 447 |
+
update_data["updated_at"] = datetime.utcnow().isoformat()
|
| 448 |
+
|
| 449 |
+
await db[SCM_RMA_COLLECTION].update_one(
|
| 450 |
+
{"rma_id": rma_id},
|
| 451 |
+
{"$set": update_data}
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
logger.info(
|
| 455 |
+
f"Updated RMA {rma_id}",
|
| 456 |
+
extra={
|
| 457 |
+
"operation": "update_rma",
|
| 458 |
+
"rma_id": rma_id
|
| 459 |
+
}
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
return await RMAService.get_rma(rma_id)
|
| 463 |
+
except HTTPException:
|
| 464 |
+
raise
|
| 465 |
+
except Exception as e:
|
| 466 |
+
logger.error(
|
| 467 |
+
f"Error updating RMA {rma_id}",
|
| 468 |
+
extra={
|
| 469 |
+
"operation": "update_rma",
|
| 470 |
+
"rma_id": rma_id,
|
| 471 |
+
"error": str(e)
|
| 472 |
+
},
|
| 473 |
+
exc_info=True
|
| 474 |
)
|
|
|
|
|
|
|
|
|
|
| 475 |
raise HTTPException(
|
| 476 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 477 |
+
detail=f"Error updating RMA {rma_id}"
|
| 478 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
@staticmethod
|
| 481 |
async def list_rmas(
|
|
|
|
| 501 |
rmas = await cursor.to_list(length=limit)
|
| 502 |
return rmas
|
| 503 |
except Exception as e:
|
| 504 |
+
logger.error(
|
| 505 |
+
"Error listing RMAs",
|
| 506 |
+
extra={
|
| 507 |
+
"operation": "list_rmas",
|
| 508 |
+
"error": str(e),
|
| 509 |
+
"merchant_id": merchant_id
|
| 510 |
+
},
|
| 511 |
+
exc_info=True
|
| 512 |
+
)
|
| 513 |
raise HTTPException(
|
| 514 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 515 |
detail="Error listing RMAs"
|
|
|
|
| 518 |
@staticmethod
|
| 519 |
async def cancel_rma(rma_id: str, cancelled_by: str, reason: str) -> Dict[str, Any]:
|
| 520 |
"""Cancel RMA"""
|
| 521 |
+
try:
|
| 522 |
+
db = get_database()
|
| 523 |
+
|
| 524 |
+
rma = await RMAService.get_rma(rma_id)
|
| 525 |
+
|
| 526 |
+
if rma["status"] in [RMAStatus.CLOSED.value, RMAStatus.CANCELLED.value]:
|
| 527 |
+
raise HTTPException(
|
| 528 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 529 |
+
detail=f"Cannot cancel RMA with status {rma['status']}"
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
+
now = datetime.utcnow()
|
| 533 |
+
update_data = {
|
| 534 |
+
"status": RMAStatus.CANCELLED.value,
|
| 535 |
+
"rejection_reason": reason,
|
| 536 |
+
"rejected_by": cancelled_by,
|
| 537 |
+
"rejected_at": now.isoformat(),
|
| 538 |
+
"updated_at": now.isoformat()
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
await db[SCM_RMA_COLLECTION].update_one(
|
| 542 |
+
{"rma_id": rma_id},
|
| 543 |
+
{"$set": update_data}
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
logger.info(
|
| 547 |
+
f"Cancelled RMA {rma_id}",
|
| 548 |
+
extra={
|
| 549 |
+
"operation": "cancel_rma",
|
| 550 |
+
"rma_id": rma_id,
|
| 551 |
+
"user_id": cancelled_by,
|
| 552 |
+
"reason": reason
|
| 553 |
+
}
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
return await RMAService.get_rma(rma_id)
|
| 557 |
+
except HTTPException:
|
| 558 |
+
raise
|
| 559 |
+
except Exception as e:
|
| 560 |
+
logger.error(
|
| 561 |
+
f"Error cancelling RMA {rma_id}",
|
| 562 |
+
extra={
|
| 563 |
+
"operation": "cancel_rma",
|
| 564 |
+
"rma_id": rma_id,
|
| 565 |
+
"error": str(e)
|
| 566 |
+
},
|
| 567 |
+
exc_info=True
|
| 568 |
+
)
|
| 569 |
raise HTTPException(
|
| 570 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 571 |
+
detail=f"Error cancelling RMA {rma_id}"
|
| 572 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/staff/controllers/router.py
CHANGED
|
@@ -4,8 +4,8 @@ Simplified POS staff management.
|
|
| 4 |
"""
|
| 5 |
from typing import Optional, List
|
| 6 |
from fastapi import APIRouter, HTTPException, Query, status, Header
|
| 7 |
-
import logging
|
| 8 |
|
|
|
|
| 9 |
from app.constants.staff_types import Designation, stafftatus
|
| 10 |
from app.staff.schemas.staff_schema import (
|
| 11 |
StaffCreateSchema,
|
|
@@ -18,7 +18,7 @@ from app.staff.schemas.staff_schema import (
|
|
| 18 |
)
|
| 19 |
from app.staff.services.staff_service import StaffService
|
| 20 |
|
| 21 |
-
logger =
|
| 22 |
|
| 23 |
router = APIRouter(
|
| 24 |
prefix="/staff",
|
|
@@ -50,7 +50,32 @@ async def create_staff(payload: StaffCreateSchema) -> StaffResponseSchema:
|
|
| 50 |
- photo_url: Profile photo URL (HTTPS only)
|
| 51 |
- notes: Additional information
|
| 52 |
"""
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
|
|
@@ -65,13 +90,43 @@ async def get_staff(staff_id: str) -> StaffResponseSchema:
|
|
| 65 |
"""
|
| 66 |
Get detailed information about a specific staff member.
|
| 67 |
"""
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
)
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
@router.put(
|
|
@@ -85,7 +140,32 @@ async def update_staff(staff_id: str, payload: StaffUpdateSchema) -> StaffRespon
|
|
| 85 |
|
| 86 |
All fields are optional - only provided fields will be updated.
|
| 87 |
"""
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
@router.delete(
|
|
@@ -96,7 +176,31 @@ async def delete_staff(staff_id: str):
|
|
| 96 |
"""
|
| 97 |
Delete a staff member (soft delete - sets status to inactive).
|
| 98 |
"""
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
@router.get(
|
|
@@ -107,18 +211,48 @@ async def get_staff_schedule(staff_id: str):
|
|
| 107 |
"""
|
| 108 |
Get working schedule for a staff member.
|
| 109 |
"""
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
@router.get(
|
|
@@ -140,7 +274,31 @@ async def get_employee(user_id: str) -> EmployeeResponse:
|
|
| 140 |
Raises:
|
| 141 |
404: Employee not found
|
| 142 |
"""
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
|
| 146 |
@router.get(
|
|
@@ -162,7 +320,31 @@ async def get_employee_by_code(employee_code: str) -> EmployeeResponse:
|
|
| 162 |
Raises:
|
| 163 |
404: Employee not found
|
| 164 |
"""
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
@router.put(
|
|
@@ -196,7 +378,34 @@ async def update_employee(
|
|
| 196 |
- Phone uniqueness (if changing phone)
|
| 197 |
- Manager validation (if changing manager)
|
| 198 |
"""
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
@router.post(
|
|
@@ -227,22 +436,45 @@ async def list_staff(
|
|
| 227 |
Returns:
|
| 228 |
List of staff matching the filters and total count
|
| 229 |
"""
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
|
| 248 |
@router.delete(
|
|
@@ -270,7 +502,33 @@ async def delete_employee(
|
|
| 270 |
404: Employee not found
|
| 271 |
400: Employee has active direct reports
|
| 272 |
"""
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
@router.get(
|
|
@@ -303,16 +561,39 @@ async def get_employee_reports(
|
|
| 303 |
Returns:
|
| 304 |
List of direct report staff
|
| 305 |
"""
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
|
| 318 |
@router.get(
|
|
@@ -332,22 +613,45 @@ async def get_employee_hierarchy(user_id: str):
|
|
| 332 |
Returns:
|
| 333 |
List of staff from top manager to current employee
|
| 334 |
"""
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
|
| 353 |
@router.patch(
|
|
@@ -380,8 +684,36 @@ async def update_employee_status(
|
|
| 380 |
- active → suspended (disciplinary)
|
| 381 |
- active/inactive/suspended → terminated (termination)
|
| 382 |
"""
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
|
| 387 |
@router.patch(
|
|
@@ -419,16 +751,43 @@ async def update_location_consent(
|
|
| 419 |
- Background tracking requires location_tracking_consent=True
|
| 420 |
- Consent timestamp is automatically recorded
|
| 421 |
"""
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
from typing import Optional, List
|
| 6 |
from fastapi import APIRouter, HTTPException, Query, status, Header
|
|
|
|
| 7 |
|
| 8 |
+
from app.core.logging import get_logger
|
| 9 |
from app.constants.staff_types import Designation, stafftatus
|
| 10 |
from app.staff.schemas.staff_schema import (
|
| 11 |
StaffCreateSchema,
|
|
|
|
| 18 |
)
|
| 19 |
from app.staff.services.staff_service import StaffService
|
| 20 |
|
| 21 |
+
logger = get_logger(__name__)
|
| 22 |
|
| 23 |
router = APIRouter(
|
| 24 |
prefix="/staff",
|
|
|
|
| 50 |
- photo_url: Profile photo URL (HTTPS only)
|
| 51 |
- notes: Additional information
|
| 52 |
"""
|
| 53 |
+
try:
|
| 54 |
+
result = await StaffService.create_staff(payload)
|
| 55 |
+
|
| 56 |
+
logger.info(
|
| 57 |
+
"Staff member created",
|
| 58 |
+
extra={
|
| 59 |
+
"operation": "create_staff",
|
| 60 |
+
"merchant_id": str(payload.merchant_id),
|
| 61 |
+
"staff_name": payload.name
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
return result
|
| 66 |
+
except HTTPException:
|
| 67 |
+
raise
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(
|
| 70 |
+
"Create staff failed",
|
| 71 |
+
extra={
|
| 72 |
+
"operation": "create_staff",
|
| 73 |
+
"error": str(e),
|
| 74 |
+
"merchant_id": str(payload.merchant_id) if hasattr(payload, "merchant_id") else None
|
| 75 |
+
},
|
| 76 |
+
exc_info=True
|
| 77 |
+
)
|
| 78 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create staff member")
|
| 79 |
|
| 80 |
|
| 81 |
|
|
|
|
| 90 |
"""
|
| 91 |
Get detailed information about a specific staff member.
|
| 92 |
"""
|
| 93 |
+
try:
|
| 94 |
+
staff = await StaffService.get_staff_by_id(staff_id)
|
| 95 |
+
if not staff:
|
| 96 |
+
logger.warning(
|
| 97 |
+
"Staff member not found",
|
| 98 |
+
extra={
|
| 99 |
+
"operation": "get_staff",
|
| 100 |
+
"staff_id": staff_id
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
raise HTTPException(
|
| 104 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 105 |
+
detail=f"Staff {staff_id} not found"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
logger.info(
|
| 109 |
+
"Staff member retrieved",
|
| 110 |
+
extra={
|
| 111 |
+
"operation": "get_staff",
|
| 112 |
+
"staff_id": staff_id,
|
| 113 |
+
"merchant_id": str(staff.merchant_id) if hasattr(staff, "merchant_id") else None
|
| 114 |
+
}
|
| 115 |
)
|
| 116 |
+
return staff
|
| 117 |
+
except HTTPException:
|
| 118 |
+
raise
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(
|
| 121 |
+
"Get staff failed",
|
| 122 |
+
extra={
|
| 123 |
+
"operation": "get_staff",
|
| 124 |
+
"staff_id": staff_id,
|
| 125 |
+
"error": str(e)
|
| 126 |
+
},
|
| 127 |
+
exc_info=True
|
| 128 |
+
)
|
| 129 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get staff member")
|
| 130 |
|
| 131 |
|
| 132 |
@router.put(
|
|
|
|
| 140 |
|
| 141 |
All fields are optional - only provided fields will be updated.
|
| 142 |
"""
|
| 143 |
+
try:
|
| 144 |
+
result = await StaffService.update_staff(staff_id, payload)
|
| 145 |
+
|
| 146 |
+
logger.info(
|
| 147 |
+
"Staff member updated",
|
| 148 |
+
extra={
|
| 149 |
+
"operation": "update_staff",
|
| 150 |
+
"staff_id": staff_id,
|
| 151 |
+
"merchant_id": str(result.merchant_id) if hasattr(result, "merchant_id") else None
|
| 152 |
+
}
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
return result
|
| 156 |
+
except HTTPException:
|
| 157 |
+
raise
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(
|
| 160 |
+
"Update staff failed",
|
| 161 |
+
extra={
|
| 162 |
+
"operation": "update_staff",
|
| 163 |
+
"staff_id": staff_id,
|
| 164 |
+
"error": str(e)
|
| 165 |
+
},
|
| 166 |
+
exc_info=True
|
| 167 |
+
)
|
| 168 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update staff member")
|
| 169 |
|
| 170 |
|
| 171 |
@router.delete(
|
|
|
|
| 176 |
"""
|
| 177 |
Delete a staff member (soft delete - sets status to inactive).
|
| 178 |
"""
|
| 179 |
+
try:
|
| 180 |
+
result = await StaffService.delete_staff(staff_id)
|
| 181 |
+
|
| 182 |
+
logger.info(
|
| 183 |
+
"Staff member deleted",
|
| 184 |
+
extra={
|
| 185 |
+
"operation": "delete_staff",
|
| 186 |
+
"staff_id": staff_id
|
| 187 |
+
}
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
return result
|
| 191 |
+
except HTTPException:
|
| 192 |
+
raise
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(
|
| 195 |
+
"Delete staff failed",
|
| 196 |
+
extra={
|
| 197 |
+
"operation": "delete_staff",
|
| 198 |
+
"staff_id": staff_id,
|
| 199 |
+
"error": str(e)
|
| 200 |
+
},
|
| 201 |
+
exc_info=True
|
| 202 |
+
)
|
| 203 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete staff member")
|
| 204 |
|
| 205 |
|
| 206 |
@router.get(
|
|
|
|
| 211 |
"""
|
| 212 |
Get working schedule for a staff member.
|
| 213 |
"""
|
| 214 |
+
try:
|
| 215 |
+
staff = await StaffService.get_staff_by_id(staff_id)
|
| 216 |
+
if not staff:
|
| 217 |
+
logger.warning(
|
| 218 |
+
"Staff member not found for schedule",
|
| 219 |
+
extra={
|
| 220 |
+
"operation": "get_staff_schedule",
|
| 221 |
+
"staff_id": staff_id
|
| 222 |
+
}
|
| 223 |
+
)
|
| 224 |
+
raise HTTPException(
|
| 225 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 226 |
+
detail=f"Staff {staff_id} not found"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
logger.info(
|
| 230 |
+
"Staff schedule retrieved",
|
| 231 |
+
extra={
|
| 232 |
+
"operation": "get_staff_schedule",
|
| 233 |
+
"staff_id": staff_id,
|
| 234 |
+
"merchant_id": str(staff.merchant_id) if hasattr(staff, "merchant_id") else None
|
| 235 |
+
}
|
| 236 |
)
|
| 237 |
+
|
| 238 |
+
return {
|
| 239 |
+
"staff_id": staff.staff_id,
|
| 240 |
+
"name": staff.name,
|
| 241 |
+
"working_hours": staff.working_hours
|
| 242 |
+
}
|
| 243 |
+
except HTTPException:
|
| 244 |
+
raise
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(
|
| 247 |
+
"Get staff schedule failed",
|
| 248 |
+
extra={
|
| 249 |
+
"operation": "get_staff_schedule",
|
| 250 |
+
"staff_id": staff_id,
|
| 251 |
+
"error": str(e)
|
| 252 |
+
},
|
| 253 |
+
exc_info=True
|
| 254 |
+
)
|
| 255 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get staff schedule")
|
| 256 |
|
| 257 |
|
| 258 |
@router.get(
|
|
|
|
| 274 |
Raises:
|
| 275 |
404: Employee not found
|
| 276 |
"""
|
| 277 |
+
try:
|
| 278 |
+
employee = await StaffService.get_employee(user_id)
|
| 279 |
+
|
| 280 |
+
logger.info(
|
| 281 |
+
"Employee retrieved",
|
| 282 |
+
extra={
|
| 283 |
+
"operation": "get_employee",
|
| 284 |
+
"user_id": user_id,
|
| 285 |
+
"merchant_id": str(employee.merchant_id) if hasattr(employee, "merchant_id") else None
|
| 286 |
+
}
|
| 287 |
+
)
|
| 288 |
+
return employee
|
| 289 |
+
except HTTPException:
|
| 290 |
+
raise
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.error(
|
| 293 |
+
"Get employee failed",
|
| 294 |
+
extra={
|
| 295 |
+
"operation": "get_employee",
|
| 296 |
+
"user_id": user_id,
|
| 297 |
+
"error": str(e)
|
| 298 |
+
},
|
| 299 |
+
exc_info=True
|
| 300 |
+
)
|
| 301 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get employee")
|
| 302 |
|
| 303 |
|
| 304 |
@router.get(
|
|
|
|
| 320 |
Raises:
|
| 321 |
404: Employee not found
|
| 322 |
"""
|
| 323 |
+
try:
|
| 324 |
+
employee = await StaffService.get_employee_by_code(employee_code)
|
| 325 |
+
|
| 326 |
+
logger.info(
|
| 327 |
+
"Employee retrieved by code",
|
| 328 |
+
extra={
|
| 329 |
+
"operation": "get_employee_by_code",
|
| 330 |
+
"employee_code": employee_code,
|
| 331 |
+
"merchant_id": str(employee.merchant_id) if hasattr(employee, "merchant_id") else None
|
| 332 |
+
}
|
| 333 |
+
)
|
| 334 |
+
return employee
|
| 335 |
+
except HTTPException:
|
| 336 |
+
raise
|
| 337 |
+
except Exception as e:
|
| 338 |
+
logger.error(
|
| 339 |
+
"Get employee by code failed",
|
| 340 |
+
extra={
|
| 341 |
+
"operation": "get_employee_by_code",
|
| 342 |
+
"employee_code": employee_code,
|
| 343 |
+
"error": str(e)
|
| 344 |
+
},
|
| 345 |
+
exc_info=True
|
| 346 |
+
)
|
| 347 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get employee by code")
|
| 348 |
|
| 349 |
|
| 350 |
@router.put(
|
|
|
|
| 378 |
- Phone uniqueness (if changing phone)
|
| 379 |
- Manager validation (if changing manager)
|
| 380 |
"""
|
| 381 |
+
try:
|
| 382 |
+
result = await StaffService.update_employee(user_id, payload, x_user_id)
|
| 383 |
+
|
| 384 |
+
logger.info(
|
| 385 |
+
"Employee updated",
|
| 386 |
+
extra={
|
| 387 |
+
"operation": "update_employee",
|
| 388 |
+
"user_id": user_id,
|
| 389 |
+
"merchant_id": str(result.merchant_id) if hasattr(result, "merchant_id") else None,
|
| 390 |
+
"updated_by": x_user_id
|
| 391 |
+
}
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
return result
|
| 395 |
+
except HTTPException:
|
| 396 |
+
raise
|
| 397 |
+
except Exception as e:
|
| 398 |
+
logger.error(
|
| 399 |
+
"Update employee failed",
|
| 400 |
+
extra={
|
| 401 |
+
"operation": "update_employee",
|
| 402 |
+
"user_id": user_id,
|
| 403 |
+
"error": str(e),
|
| 404 |
+
"updated_by": x_user_id
|
| 405 |
+
},
|
| 406 |
+
exc_info=True
|
| 407 |
+
)
|
| 408 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update employee")
|
| 409 |
|
| 410 |
|
| 411 |
@router.post(
|
|
|
|
| 436 |
Returns:
|
| 437 |
List of staff matching the filters and total count
|
| 438 |
"""
|
| 439 |
+
try:
|
| 440 |
+
items, total = await StaffService.list_staff(
|
| 441 |
+
designation=payload.designation,
|
| 442 |
+
manager_id=payload.manager_id,
|
| 443 |
+
status_filter=payload.status,
|
| 444 |
+
region=payload.region,
|
| 445 |
+
skip=payload.skip,
|
| 446 |
+
limit=payload.limit,
|
| 447 |
+
projection_list=payload.projection_list
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
logger.info(
|
| 451 |
+
"Staff listed",
|
| 452 |
+
extra={
|
| 453 |
+
"operation": "list_staff",
|
| 454 |
+
"count": len(items),
|
| 455 |
+
"total": total,
|
| 456 |
+
"manager_id": payload.manager_id
|
| 457 |
+
}
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
return StaffListResponse(
|
| 461 |
+
items=items,
|
| 462 |
+
total=total,
|
| 463 |
+
skip=payload.skip,
|
| 464 |
+
limit=payload.limit
|
| 465 |
+
)
|
| 466 |
+
except HTTPException:
|
| 467 |
+
raise
|
| 468 |
+
except Exception as e:
|
| 469 |
+
logger.error(
|
| 470 |
+
"List staff failed",
|
| 471 |
+
extra={
|
| 472 |
+
"operation": "list_staff",
|
| 473 |
+
"error": str(e)
|
| 474 |
+
},
|
| 475 |
+
exc_info=True
|
| 476 |
+
)
|
| 477 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to list staff")
|
| 478 |
|
| 479 |
|
| 480 |
@router.delete(
|
|
|
|
| 502 |
404: Employee not found
|
| 503 |
400: Employee has active direct reports
|
| 504 |
"""
|
| 505 |
+
try:
|
| 506 |
+
result = await StaffService.delete_employee(user_id, x_user_id)
|
| 507 |
+
|
| 508 |
+
logger.info(
|
| 509 |
+
"Employee deleted",
|
| 510 |
+
extra={
|
| 511 |
+
"operation": "delete_employee",
|
| 512 |
+
"user_id": user_id,
|
| 513 |
+
"deleted_by": x_user_id
|
| 514 |
+
}
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
return result
|
| 518 |
+
except HTTPException:
|
| 519 |
+
raise
|
| 520 |
+
except Exception as e:
|
| 521 |
+
logger.error(
|
| 522 |
+
"Delete employee failed",
|
| 523 |
+
extra={
|
| 524 |
+
"operation": "delete_employee",
|
| 525 |
+
"user_id": user_id,
|
| 526 |
+
"error": str(e),
|
| 527 |
+
"deleted_by": x_user_id
|
| 528 |
+
},
|
| 529 |
+
exc_info=True
|
| 530 |
+
)
|
| 531 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete employee")
|
| 532 |
|
| 533 |
|
| 534 |
@router.get(
|
|
|
|
| 561 |
Returns:
|
| 562 |
List of direct report staff
|
| 563 |
"""
|
| 564 |
+
try:
|
| 565 |
+
# First verify employee exists
|
| 566 |
+
await StaffService.get_employee(user_id)
|
| 567 |
+
|
| 568 |
+
items, _ = await StaffService.list_staff(
|
| 569 |
+
manager_id=user_id,
|
| 570 |
+
status_filter=status_filter,
|
| 571 |
+
skip=skip,
|
| 572 |
+
limit=limit
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
logger.info(
|
| 576 |
+
"Employee reports listed",
|
| 577 |
+
extra={
|
| 578 |
+
"operation": "get_employee_reports",
|
| 579 |
+
"user_id": user_id,
|
| 580 |
+
"count": len(items)
|
| 581 |
+
}
|
| 582 |
+
)
|
| 583 |
+
return items
|
| 584 |
+
except HTTPException:
|
| 585 |
+
raise
|
| 586 |
+
except Exception as e:
|
| 587 |
+
logger.error(
|
| 588 |
+
"Get employee reports failed",
|
| 589 |
+
extra={
|
| 590 |
+
"operation": "get_employee_reports",
|
| 591 |
+
"user_id": user_id,
|
| 592 |
+
"error": str(e)
|
| 593 |
+
},
|
| 594 |
+
exc_info=True
|
| 595 |
+
)
|
| 596 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get employee reports")
|
| 597 |
|
| 598 |
|
| 599 |
@router.get(
|
|
|
|
| 613 |
Returns:
|
| 614 |
List of staff from top manager to current employee
|
| 615 |
"""
|
| 616 |
+
try:
|
| 617 |
+
hierarchy = []
|
| 618 |
+
current_id = user_id
|
| 619 |
+
visited = set() # Prevent infinite loops
|
| 620 |
+
|
| 621 |
+
# Traverse up the hierarchy
|
| 622 |
+
while current_id and current_id not in visited:
|
| 623 |
+
visited.add(current_id)
|
| 624 |
+
employee_data = await StaffService.get_employee(current_id)
|
| 625 |
+
hierarchy.insert(0, employee_data) # Insert at beginning to maintain order
|
| 626 |
+
current_id = employee_data.manager_id
|
| 627 |
+
|
| 628 |
+
logger.info(
|
| 629 |
+
"Employee hierarchy retrieved",
|
| 630 |
+
extra={
|
| 631 |
+
"operation": "get_employee_hierarchy",
|
| 632 |
+
"user_id": user_id,
|
| 633 |
+
"depth": len(hierarchy)
|
| 634 |
+
}
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
return {
|
| 638 |
+
"user_id": user_id,
|
| 639 |
+
"depth": len(hierarchy),
|
| 640 |
+
"hierarchy": hierarchy
|
| 641 |
+
}
|
| 642 |
+
except HTTPException:
|
| 643 |
+
raise
|
| 644 |
+
except Exception as e:
|
| 645 |
+
logger.error(
|
| 646 |
+
"Get employee hierarchy failed",
|
| 647 |
+
extra={
|
| 648 |
+
"operation": "get_employee_hierarchy",
|
| 649 |
+
"user_id": user_id,
|
| 650 |
+
"error": str(e)
|
| 651 |
+
},
|
| 652 |
+
exc_info=True
|
| 653 |
+
)
|
| 654 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get employee hierarchy")
|
| 655 |
|
| 656 |
|
| 657 |
@router.patch(
|
|
|
|
| 684 |
- active → suspended (disciplinary)
|
| 685 |
- active/inactive/suspended → terminated (termination)
|
| 686 |
"""
|
| 687 |
+
try:
|
| 688 |
+
update_payload = EmployeeUpdate(status=new_status)
|
| 689 |
+
result = await StaffService.update_employee(user_id, update_payload, x_user_id)
|
| 690 |
+
|
| 691 |
+
logger.info(
|
| 692 |
+
"Employee status updated",
|
| 693 |
+
extra={
|
| 694 |
+
"operation": "update_employee_status",
|
| 695 |
+
"user_id": user_id,
|
| 696 |
+
"new_status": new_status,
|
| 697 |
+
"updated_by": x_user_id
|
| 698 |
+
}
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
return result
|
| 702 |
+
except HTTPException:
|
| 703 |
+
raise
|
| 704 |
+
except Exception as e:
|
| 705 |
+
logger.error(
|
| 706 |
+
"Update employee status failed",
|
| 707 |
+
extra={
|
| 708 |
+
"operation": "update_employee_status",
|
| 709 |
+
"user_id": user_id,
|
| 710 |
+
"new_status": new_status,
|
| 711 |
+
"error": str(e),
|
| 712 |
+
"updated_by": x_user_id
|
| 713 |
+
},
|
| 714 |
+
exc_info=True
|
| 715 |
+
)
|
| 716 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update employee status")
|
| 717 |
|
| 718 |
|
| 719 |
@router.patch(
|
|
|
|
| 751 |
- Background tracking requires location_tracking_consent=True
|
| 752 |
- Consent timestamp is automatically recorded
|
| 753 |
"""
|
| 754 |
+
try:
|
| 755 |
+
from datetime import datetime
|
| 756 |
+
from app.staff.schemas.staff_schema import LocationSettingsSchema
|
| 757 |
+
|
| 758 |
+
location_settings = LocationSettingsSchema(
|
| 759 |
+
location_tracking_consent=location_tracking_consent,
|
| 760 |
+
consent_given_at=datetime.utcnow() if location_tracking_consent else None,
|
| 761 |
+
consent_ip=consent_ip,
|
| 762 |
+
consent_device=consent_device,
|
| 763 |
+
background_tracking_opt_in=background_tracking_opt_in
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
update_payload = EmployeeUpdate(location_settings=location_settings)
|
| 767 |
+
result = await StaffService.update_employee(user_id, update_payload, x_user_id)
|
| 768 |
+
|
| 769 |
+
logger.info(
|
| 770 |
+
"Location consent updated",
|
| 771 |
+
extra={
|
| 772 |
+
"operation": "update_location_consent",
|
| 773 |
+
"user_id": user_id,
|
| 774 |
+
"location_tracking_consent": location_tracking_consent,
|
| 775 |
+
"updated_by": x_user_id
|
| 776 |
+
}
|
| 777 |
+
)
|
| 778 |
+
|
| 779 |
+
return result
|
| 780 |
+
except HTTPException:
|
| 781 |
+
raise
|
| 782 |
+
except Exception as e:
|
| 783 |
+
logger.error(
|
| 784 |
+
"Update location consent failed",
|
| 785 |
+
extra={
|
| 786 |
+
"operation": "update_location_consent",
|
| 787 |
+
"user_id": user_id,
|
| 788 |
+
"error": str(e),
|
| 789 |
+
"updated_by": x_user_id
|
| 790 |
+
},
|
| 791 |
+
exc_info=True
|
| 792 |
+
)
|
| 793 |
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update location consent")
|
app/staff/services/staff_service.py
CHANGED
|
@@ -5,7 +5,7 @@ Syncs staff data to both MongoDB and PostgreSQL (trans.pos_staff_ref).
|
|
| 5 |
from datetime import datetime
|
| 6 |
from typing import Optional, List, Dict, Any, Union
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
-
|
| 9 |
import secrets
|
| 10 |
from sqlalchemy import text
|
| 11 |
|
|
@@ -22,7 +22,7 @@ from app.staff.schemas.staff_schema import (
|
|
| 22 |
)
|
| 23 |
from app.constants.staff_types import Designation, stafftatus
|
| 24 |
|
| 25 |
-
logger =
|
| 26 |
|
| 27 |
|
| 28 |
|
|
@@ -39,7 +39,7 @@ async def sync_staff_to_postgres(staff_id: str, merchant_id: str, staff_name: st
|
|
| 39 |
try:
|
| 40 |
async with get_postgres_session() as session:
|
| 41 |
if session is None:
|
| 42 |
-
logger.warning("PostgreSQL not available, skipping staff sync")
|
| 43 |
return
|
| 44 |
|
| 45 |
query = text("""
|
|
@@ -57,10 +57,25 @@ async def sync_staff_to_postgres(staff_id: str, merchant_id: str, staff_name: st
|
|
| 57 |
"staff_name": staff_name
|
| 58 |
})
|
| 59 |
await session.commit()
|
| 60 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
except Exception as e:
|
| 63 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
# Don't raise - PostgreSQL sync is secondary to MongoDB
|
| 65 |
def generate_staff_id() -> str:
|
| 66 |
"""Generate a unique staff ID."""
|
|
@@ -102,13 +117,28 @@ class StaffService:
|
|
| 102 |
staff_name=payload.name
|
| 103 |
)
|
| 104 |
|
| 105 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
# Return response
|
| 108 |
return StaffResponseSchema(**staff_data)
|
| 109 |
|
| 110 |
except Exception as e:
|
| 111 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
raise HTTPException(
|
| 113 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 114 |
detail=f"Error creating staff: {str(e)}"
|
|
@@ -123,7 +153,15 @@ class StaffService:
|
|
| 123 |
return None
|
| 124 |
return StaffResponseSchema(**staff)
|
| 125 |
except Exception as e:
|
| 126 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
raise HTTPException(
|
| 128 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 129 |
detail="Error retrieving staff"
|
|
@@ -177,14 +215,35 @@ class StaffService:
|
|
| 177 |
)
|
| 178 |
|
| 179 |
if result.modified_count == 0:
|
| 180 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
return updated_staff
|
| 185 |
|
| 186 |
except Exception as e:
|
| 187 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
raise HTTPException(
|
| 189 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 190 |
detail=f"Error updating staff: {str(e)}"
|
|
@@ -223,11 +282,25 @@ class StaffService:
|
|
| 223 |
}
|
| 224 |
)
|
| 225 |
|
| 226 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
return {"message": f"Staff {staff_id} deleted successfully"}
|
| 228 |
|
| 229 |
except Exception as e:
|
| 230 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
raise HTTPException(
|
| 232 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 233 |
detail="Error deleting staff"
|
|
@@ -247,7 +320,15 @@ class StaffService:
|
|
| 247 |
except HTTPException:
|
| 248 |
raise
|
| 249 |
except Exception as e:
|
| 250 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
raise HTTPException(
|
| 252 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 253 |
detail="Error retrieving employee"
|
|
@@ -269,7 +350,15 @@ class StaffService:
|
|
| 269 |
except HTTPException:
|
| 270 |
raise
|
| 271 |
except Exception as e:
|
| 272 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
raise HTTPException(
|
| 274 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 275 |
detail="Error retrieving employee"
|
|
@@ -412,7 +501,14 @@ class StaffService:
|
|
| 412 |
return [EmployeeResponse(**staff) for staff in staff_list], total
|
| 413 |
|
| 414 |
except Exception as e:
|
| 415 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
raise HTTPException(
|
| 417 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 418 |
detail="Error listing staff"
|
|
|
|
| 5 |
from datetime import datetime
|
| 6 |
from typing import Optional, List, Dict, Any, Union
|
| 7 |
from fastapi import HTTPException, status
|
| 8 |
+
from app.core.logging import get_logger
|
| 9 |
import secrets
|
| 10 |
from sqlalchemy import text
|
| 11 |
|
|
|
|
| 22 |
)
|
| 23 |
from app.constants.staff_types import Designation, stafftatus
|
| 24 |
|
| 25 |
+
logger = get_logger(__name__)
|
| 26 |
|
| 27 |
|
| 28 |
|
|
|
|
| 39 |
try:
|
| 40 |
async with get_postgres_session() as session:
|
| 41 |
if session is None:
|
| 42 |
+
logger.warning("PostgreSQL not available, skipping staff sync", extra={"operation": "sync_staff_to_postgres"})
|
| 43 |
return
|
| 44 |
|
| 45 |
query = text("""
|
|
|
|
| 57 |
"staff_name": staff_name
|
| 58 |
})
|
| 59 |
await session.commit()
|
| 60 |
+
logger.info(
|
| 61 |
+
f"Synced staff {staff_id} to trans.pos_staff_ref",
|
| 62 |
+
extra={
|
| 63 |
+
"operation": "sync_staff_to_postgres",
|
| 64 |
+
"staff_id": staff_id,
|
| 65 |
+
"merchant_id": str(merchant_id)
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
|
| 69 |
except Exception as e:
|
| 70 |
+
logger.error(
|
| 71 |
+
f"Failed to sync staff {staff_id} to PostgreSQL",
|
| 72 |
+
extra={
|
| 73 |
+
"operation": "sync_staff_to_postgres",
|
| 74 |
+
"staff_id": staff_id,
|
| 75 |
+
"error": str(e)
|
| 76 |
+
},
|
| 77 |
+
exc_info=True
|
| 78 |
+
)
|
| 79 |
# Don't raise - PostgreSQL sync is secondary to MongoDB
|
| 80 |
def generate_staff_id() -> str:
|
| 81 |
"""Generate a unique staff ID."""
|
|
|
|
| 117 |
staff_name=payload.name
|
| 118 |
)
|
| 119 |
|
| 120 |
+
logger.info(
|
| 121 |
+
f"Created staff {staff_id}",
|
| 122 |
+
extra={
|
| 123 |
+
"operation": "create_staff",
|
| 124 |
+
"staff_id": staff_id,
|
| 125 |
+
"merchant_id": str(payload.merchant_id)
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
|
| 129 |
# Return response
|
| 130 |
return StaffResponseSchema(**staff_data)
|
| 131 |
|
| 132 |
except Exception as e:
|
| 133 |
+
logger.error(
|
| 134 |
+
"Error creating staff",
|
| 135 |
+
extra={
|
| 136 |
+
"operation": "create_staff",
|
| 137 |
+
"error": str(e),
|
| 138 |
+
"merchant_id": str(payload.merchant_id) if hasattr(payload, "merchant_id") else None
|
| 139 |
+
},
|
| 140 |
+
exc_info=True
|
| 141 |
+
)
|
| 142 |
raise HTTPException(
|
| 143 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 144 |
detail=f"Error creating staff: {str(e)}"
|
|
|
|
| 153 |
return None
|
| 154 |
return StaffResponseSchema(**staff)
|
| 155 |
except Exception as e:
|
| 156 |
+
logger.error(
|
| 157 |
+
f"Error fetching staff {staff_id}",
|
| 158 |
+
extra={
|
| 159 |
+
"operation": "get_staff_by_id",
|
| 160 |
+
"staff_id": staff_id,
|
| 161 |
+
"error": str(e)
|
| 162 |
+
},
|
| 163 |
+
exc_info=True
|
| 164 |
+
)
|
| 165 |
raise HTTPException(
|
| 166 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 167 |
detail="Error retrieving staff"
|
|
|
|
| 215 |
)
|
| 216 |
|
| 217 |
if result.modified_count == 0:
|
| 218 |
+
logger.warning(
|
| 219 |
+
f"No changes made to staff {staff_id}",
|
| 220 |
+
extra={
|
| 221 |
+
"operation": "update_staff",
|
| 222 |
+
"staff_id": staff_id
|
| 223 |
+
}
|
| 224 |
+
)
|
| 225 |
|
| 226 |
+
logger.info(
|
| 227 |
+
f"Updated staff {staff_id}",
|
| 228 |
+
extra={
|
| 229 |
+
"operation": "update_staff",
|
| 230 |
+
"staff_id": staff_id,
|
| 231 |
+
"merchant_id": str(updated_staff.merchant_id) if updated_staff else None
|
| 232 |
+
}
|
| 233 |
+
)
|
| 234 |
|
| 235 |
return updated_staff
|
| 236 |
|
| 237 |
except Exception as e:
|
| 238 |
+
logger.error(
|
| 239 |
+
f"Error updating staff {staff_id}",
|
| 240 |
+
extra={
|
| 241 |
+
"operation": "update_staff",
|
| 242 |
+
"staff_id": staff_id,
|
| 243 |
+
"error": str(e)
|
| 244 |
+
},
|
| 245 |
+
exc_info=True
|
| 246 |
+
)
|
| 247 |
raise HTTPException(
|
| 248 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 249 |
detail=f"Error updating staff: {str(e)}"
|
|
|
|
| 282 |
}
|
| 283 |
)
|
| 284 |
|
| 285 |
+
logger.info(
|
| 286 |
+
f"Deleted staff {staff_id}",
|
| 287 |
+
extra={
|
| 288 |
+
"operation": "delete_staff",
|
| 289 |
+
"staff_id": staff_id
|
| 290 |
+
}
|
| 291 |
+
)
|
| 292 |
return {"message": f"Staff {staff_id} deleted successfully"}
|
| 293 |
|
| 294 |
except Exception as e:
|
| 295 |
+
logger.error(
|
| 296 |
+
f"Error deleting staff {staff_id}",
|
| 297 |
+
extra={
|
| 298 |
+
"operation": "delete_staff",
|
| 299 |
+
"staff_id": staff_id,
|
| 300 |
+
"error": str(e)
|
| 301 |
+
},
|
| 302 |
+
exc_info=True
|
| 303 |
+
)
|
| 304 |
raise HTTPException(
|
| 305 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 306 |
detail="Error deleting staff"
|
|
|
|
| 320 |
except HTTPException:
|
| 321 |
raise
|
| 322 |
except Exception as e:
|
| 323 |
+
logger.error(
|
| 324 |
+
f"Error fetching employee {user_id}",
|
| 325 |
+
extra={
|
| 326 |
+
"operation": "get_employee",
|
| 327 |
+
"user_id": user_id,
|
| 328 |
+
"error": str(e)
|
| 329 |
+
},
|
| 330 |
+
exc_info=True
|
| 331 |
+
)
|
| 332 |
raise HTTPException(
|
| 333 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 334 |
detail="Error retrieving employee"
|
|
|
|
| 350 |
except HTTPException:
|
| 351 |
raise
|
| 352 |
except Exception as e:
|
| 353 |
+
logger.error(
|
| 354 |
+
f"Error fetching employee by code {employee_code}",
|
| 355 |
+
extra={
|
| 356 |
+
"operation": "get_employee_by_code",
|
| 357 |
+
"employee_code": employee_code,
|
| 358 |
+
"error": str(e)
|
| 359 |
+
},
|
| 360 |
+
exc_info=True
|
| 361 |
+
)
|
| 362 |
raise HTTPException(
|
| 363 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 364 |
detail="Error retrieving employee"
|
|
|
|
| 501 |
return [EmployeeResponse(**staff) for staff in staff_list], total
|
| 502 |
|
| 503 |
except Exception as e:
|
| 504 |
+
logger.error(
|
| 505 |
+
"Error listing staff",
|
| 506 |
+
extra={
|
| 507 |
+
"operation": "list_staff",
|
| 508 |
+
"error": str(e)
|
| 509 |
+
},
|
| 510 |
+
exc_info=True
|
| 511 |
+
)
|
| 512 |
raise HTTPException(
|
| 513 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 514 |
detail="Error listing staff"
|
app/sync/catalogue_services/sync_service.py
CHANGED
|
@@ -5,12 +5,12 @@ Handles CRUD operations to keep PostgreSQL catalogue_service_ref table in sync w
|
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlalchemy import select, delete, text
|
| 7 |
from typing import Optional, Dict, Any, List
|
| 8 |
-
import logging
|
| 9 |
from datetime import datetime
|
|
|
|
| 10 |
|
| 11 |
from app.sync.models import CatalogueServiceRef
|
| 12 |
|
| 13 |
-
logger =
|
| 14 |
|
| 15 |
|
| 16 |
class CatalogueServiceSyncService:
|
|
@@ -66,10 +66,13 @@ class CatalogueServiceSyncService:
|
|
| 66 |
"""
|
| 67 |
await self.pg_session.execute(text(create_table_sql))
|
| 68 |
await self.pg_session.commit()
|
| 69 |
-
logger.info("✅ Created catalogue_service_ref table in trans schema")
|
| 70 |
|
| 71 |
except Exception as e:
|
| 72 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
def _extract_sync_data(self, service_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 75 |
"""Extract relevant data for PostgreSQL sync"""
|
|
@@ -116,12 +119,27 @@ class CatalogueServiceSyncService:
|
|
| 116 |
await self.pg_session.commit()
|
| 117 |
await self.pg_session.refresh(service_ref)
|
| 118 |
|
| 119 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
return service_ref
|
| 121 |
|
| 122 |
except Exception as e:
|
| 123 |
await self.pg_session.rollback()
|
| 124 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
raise
|
| 126 |
|
| 127 |
async def update_catalogue_service_ref(self, service_id: str, service_data: Dict[str, Any]) -> Optional[CatalogueServiceRef]:
|
|
@@ -135,7 +153,10 @@ class CatalogueServiceSyncService:
|
|
| 135 |
service_ref = result.scalar_one_or_none()
|
| 136 |
|
| 137 |
if not service_ref:
|
| 138 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 139 |
return None
|
| 140 |
|
| 141 |
# Update with new data
|
|
@@ -149,12 +170,19 @@ class CatalogueServiceSyncService:
|
|
| 149 |
await self.pg_session.commit()
|
| 150 |
await self.pg_session.refresh(service_ref)
|
| 151 |
|
| 152 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 153 |
return service_ref
|
| 154 |
|
| 155 |
except Exception as e:
|
| 156 |
await self.pg_session.rollback()
|
| 157 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
raise
|
| 159 |
|
| 160 |
async def delete_catalogue_service_ref(self, service_id: str) -> bool:
|
|
@@ -168,15 +196,25 @@ class CatalogueServiceSyncService:
|
|
| 168 |
await self.pg_session.commit()
|
| 169 |
|
| 170 |
if deleted_count > 0:
|
| 171 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 172 |
return True
|
| 173 |
else:
|
| 174 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 175 |
return False
|
| 176 |
|
| 177 |
except Exception as e:
|
| 178 |
await self.pg_session.rollback()
|
| 179 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
raise
|
| 181 |
|
| 182 |
async def get_catalogue_service_ref(self, service_id: str) -> Optional[CatalogueServiceRef]:
|
|
@@ -188,7 +226,11 @@ class CatalogueServiceSyncService:
|
|
| 188 |
return result.scalar_one_or_none()
|
| 189 |
|
| 190 |
except Exception as e:
|
| 191 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
raise
|
| 193 |
|
| 194 |
async def sync_catalogue_service_status(self, service_id: str, status: str) -> bool:
|
|
@@ -203,15 +245,25 @@ class CatalogueServiceSyncService:
|
|
| 203 |
service_ref.status = status
|
| 204 |
service_ref.updated_at = datetime.utcnow()
|
| 205 |
await self.pg_session.commit()
|
| 206 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 207 |
return True
|
| 208 |
else:
|
| 209 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 210 |
return False
|
| 211 |
|
| 212 |
except Exception as e:
|
| 213 |
await self.pg_session.rollback()
|
| 214 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
raise
|
| 216 |
|
| 217 |
async def list_catalogue_services_by_merchant(self, merchant_id: str, limit: int = 100, offset: int = 0) -> List[CatalogueServiceRef]:
|
|
@@ -227,7 +279,11 @@ class CatalogueServiceSyncService:
|
|
| 227 |
return result.scalars().all()
|
| 228 |
|
| 229 |
except Exception as e:
|
| 230 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
raise
|
| 232 |
|
| 233 |
async def search_catalogue_services(self, merchant_id: str, search_term: str, limit: int = 50) -> List[CatalogueServiceRef]:
|
|
@@ -249,7 +305,11 @@ class CatalogueServiceSyncService:
|
|
| 249 |
return result.scalars().all()
|
| 250 |
|
| 251 |
except Exception as e:
|
| 252 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
raise
|
| 254 |
|
| 255 |
async def list_catalogue_services_by_category(self, merchant_id: str, category_id: str) -> List[CatalogueServiceRef]:
|
|
@@ -266,7 +326,11 @@ class CatalogueServiceSyncService:
|
|
| 266 |
return result.scalars().all()
|
| 267 |
|
| 268 |
except Exception as e:
|
| 269 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
raise
|
| 271 |
|
| 272 |
async def list_catalogue_services_by_status(self, merchant_id: str, status: str) -> List[CatalogueServiceRef]:
|
|
@@ -283,7 +347,11 @@ class CatalogueServiceSyncService:
|
|
| 283 |
return result.scalars().all()
|
| 284 |
|
| 285 |
except Exception as e:
|
| 286 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
raise
|
| 288 |
|
| 289 |
async def get_service_pricing_summary(self, merchant_id: str) -> Dict[str, Any]:
|
|
@@ -314,5 +382,9 @@ class CatalogueServiceSyncService:
|
|
| 314 |
}
|
| 315 |
|
| 316 |
except Exception as e:
|
| 317 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
raise
|
|
|
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlalchemy import select, delete, text
|
| 7 |
from typing import Optional, Dict, Any, List
|
|
|
|
| 8 |
from datetime import datetime
|
| 9 |
+
from app.core.logging import get_logger
|
| 10 |
|
| 11 |
from app.sync.models import CatalogueServiceRef
|
| 12 |
|
| 13 |
+
logger = get_logger(__name__)
|
| 14 |
|
| 15 |
|
| 16 |
class CatalogueServiceSyncService:
|
|
|
|
| 66 |
"""
|
| 67 |
await self.pg_session.execute(text(create_table_sql))
|
| 68 |
await self.pg_session.commit()
|
| 69 |
+
logger.info("✅ Created catalogue_service_ref table in trans schema", extra={"operation": "ensure_schema"})
|
| 70 |
|
| 71 |
except Exception as e:
|
| 72 |
+
logger.warning(
|
| 73 |
+
f"Schema/table creation failed (may already exist): {e}",
|
| 74 |
+
extra={"operation": "ensure_schema", "error": str(e)}
|
| 75 |
+
)
|
| 76 |
|
| 77 |
def _extract_sync_data(self, service_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 78 |
"""Extract relevant data for PostgreSQL sync"""
|
|
|
|
| 119 |
await self.pg_session.commit()
|
| 120 |
await self.pg_session.refresh(service_ref)
|
| 121 |
|
| 122 |
+
logger.info(
|
| 123 |
+
f"Created catalogue service reference: {service_data.get('_id') or service_data.get('service_id')}",
|
| 124 |
+
extra={
|
| 125 |
+
"operation": "create_catalogue_service_ref",
|
| 126 |
+
"service_id": service_data.get('_id') or service_data.get('service_id'),
|
| 127 |
+
"merchant_id": service_data.get('merchant_id')
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
return service_ref
|
| 131 |
|
| 132 |
except Exception as e:
|
| 133 |
await self.pg_session.rollback()
|
| 134 |
+
logger.error(
|
| 135 |
+
f"Error creating catalogue service reference: {e}",
|
| 136 |
+
extra={
|
| 137 |
+
"operation": "create_catalogue_service_ref",
|
| 138 |
+
"service_id": service_data.get('_id') or service_data.get('service_id'),
|
| 139 |
+
"error": str(e)
|
| 140 |
+
},
|
| 141 |
+
exc_info=True
|
| 142 |
+
)
|
| 143 |
raise
|
| 144 |
|
| 145 |
async def update_catalogue_service_ref(self, service_id: str, service_data: Dict[str, Any]) -> Optional[CatalogueServiceRef]:
|
|
|
|
| 153 |
service_ref = result.scalar_one_or_none()
|
| 154 |
|
| 155 |
if not service_ref:
|
| 156 |
+
logger.warning(
|
| 157 |
+
f"Catalogue service reference not found for update: {service_id}",
|
| 158 |
+
extra={"operation": "update_catalogue_service_ref", "service_id": service_id}
|
| 159 |
+
)
|
| 160 |
return None
|
| 161 |
|
| 162 |
# Update with new data
|
|
|
|
| 170 |
await self.pg_session.commit()
|
| 171 |
await self.pg_session.refresh(service_ref)
|
| 172 |
|
| 173 |
+
logger.info(
|
| 174 |
+
f"Updated catalogue service reference: {service_id}",
|
| 175 |
+
extra={"operation": "update_catalogue_service_ref", "service_id": service_id, "merchant_id": sync_data.get('merchant_id')}
|
| 176 |
+
)
|
| 177 |
return service_ref
|
| 178 |
|
| 179 |
except Exception as e:
|
| 180 |
await self.pg_session.rollback()
|
| 181 |
+
logger.error(
|
| 182 |
+
f"Error updating catalogue service reference: {e}",
|
| 183 |
+
extra={"operation": "update_catalogue_service_ref", "service_id": service_id, "error": str(e)},
|
| 184 |
+
exc_info=True
|
| 185 |
+
)
|
| 186 |
raise
|
| 187 |
|
| 188 |
async def delete_catalogue_service_ref(self, service_id: str) -> bool:
|
|
|
|
| 196 |
await self.pg_session.commit()
|
| 197 |
|
| 198 |
if deleted_count > 0:
|
| 199 |
+
logger.info(
|
| 200 |
+
f"Deleted catalogue service reference: {service_id}",
|
| 201 |
+
extra={"operation": "delete_catalogue_service_ref", "service_id": service_id}
|
| 202 |
+
)
|
| 203 |
return True
|
| 204 |
else:
|
| 205 |
+
logger.warning(
|
| 206 |
+
f"Catalogue service reference not found for deletion: {service_id}",
|
| 207 |
+
extra={"operation": "delete_catalogue_service_ref", "service_id": service_id}
|
| 208 |
+
)
|
| 209 |
return False
|
| 210 |
|
| 211 |
except Exception as e:
|
| 212 |
await self.pg_session.rollback()
|
| 213 |
+
logger.error(
|
| 214 |
+
f"Error deleting catalogue service reference: {e}",
|
| 215 |
+
extra={"operation": "delete_catalogue_service_ref", "service_id": service_id, "error": str(e)},
|
| 216 |
+
exc_info=True
|
| 217 |
+
)
|
| 218 |
raise
|
| 219 |
|
| 220 |
async def get_catalogue_service_ref(self, service_id: str) -> Optional[CatalogueServiceRef]:
|
|
|
|
| 226 |
return result.scalar_one_or_none()
|
| 227 |
|
| 228 |
except Exception as e:
|
| 229 |
+
logger.error(
|
| 230 |
+
f"Error getting catalogue service reference: {e}",
|
| 231 |
+
extra={"operation": "get_catalogue_service_ref", "service_id": service_id, "error": str(e)},
|
| 232 |
+
exc_info=True
|
| 233 |
+
)
|
| 234 |
raise
|
| 235 |
|
| 236 |
async def sync_catalogue_service_status(self, service_id: str, status: str) -> bool:
|
|
|
|
| 245 |
service_ref.status = status
|
| 246 |
service_ref.updated_at = datetime.utcnow()
|
| 247 |
await self.pg_session.commit()
|
| 248 |
+
logger.info(
|
| 249 |
+
f"Updated catalogue service status: {service_id} -> {status}",
|
| 250 |
+
extra={"operation": "sync_catalogue_service_status", "service_id": service_id, "status": status}
|
| 251 |
+
)
|
| 252 |
return True
|
| 253 |
else:
|
| 254 |
+
logger.warning(
|
| 255 |
+
f"Catalogue service reference not found for status update: {service_id}",
|
| 256 |
+
extra={"operation": "sync_catalogue_service_status", "service_id": service_id}
|
| 257 |
+
)
|
| 258 |
return False
|
| 259 |
|
| 260 |
except Exception as e:
|
| 261 |
await self.pg_session.rollback()
|
| 262 |
+
logger.error(
|
| 263 |
+
f"Error updating catalogue service status: {e}",
|
| 264 |
+
extra={"operation": "sync_catalogue_service_status", "service_id": service_id, "status": status, "error": str(e)},
|
| 265 |
+
exc_info=True
|
| 266 |
+
)
|
| 267 |
raise
|
| 268 |
|
| 269 |
async def list_catalogue_services_by_merchant(self, merchant_id: str, limit: int = 100, offset: int = 0) -> List[CatalogueServiceRef]:
|
|
|
|
| 279 |
return result.scalars().all()
|
| 280 |
|
| 281 |
except Exception as e:
|
| 282 |
+
logger.error(
|
| 283 |
+
f"Error listing catalogue services by merchant: {e}",
|
| 284 |
+
extra={"operation": "list_catalogue_services_by_merchant", "merchant_id": merchant_id, "error": str(e)},
|
| 285 |
+
exc_info=True
|
| 286 |
+
)
|
| 287 |
raise
|
| 288 |
|
| 289 |
async def search_catalogue_services(self, merchant_id: str, search_term: str, limit: int = 50) -> List[CatalogueServiceRef]:
|
|
|
|
| 305 |
return result.scalars().all()
|
| 306 |
|
| 307 |
except Exception as e:
|
| 308 |
+
logger.error(
|
| 309 |
+
f"Error searching catalogue services: {e}",
|
| 310 |
+
extra={"operation": "search_catalogue_services", "merchant_id": merchant_id, "search_term": search_term, "error": str(e)},
|
| 311 |
+
exc_info=True
|
| 312 |
+
)
|
| 313 |
raise
|
| 314 |
|
| 315 |
async def list_catalogue_services_by_category(self, merchant_id: str, category_id: str) -> List[CatalogueServiceRef]:
|
|
|
|
| 326 |
return result.scalars().all()
|
| 327 |
|
| 328 |
except Exception as e:
|
| 329 |
+
logger.error(
|
| 330 |
+
f"Error listing catalogue services by category: {e}",
|
| 331 |
+
extra={"operation": "list_catalogue_services_by_category", "merchant_id": merchant_id, "category_id": category_id, "error": str(e)},
|
| 332 |
+
exc_info=True
|
| 333 |
+
)
|
| 334 |
raise
|
| 335 |
|
| 336 |
async def list_catalogue_services_by_status(self, merchant_id: str, status: str) -> List[CatalogueServiceRef]:
|
|
|
|
| 347 |
return result.scalars().all()
|
| 348 |
|
| 349 |
except Exception as e:
|
| 350 |
+
logger.error(
|
| 351 |
+
f"Error listing catalogue services by status: {e}",
|
| 352 |
+
extra={"operation": "list_catalogue_services_by_status", "merchant_id": merchant_id, "status": status, "error": str(e)},
|
| 353 |
+
exc_info=True
|
| 354 |
+
)
|
| 355 |
raise
|
| 356 |
|
| 357 |
async def get_service_pricing_summary(self, merchant_id: str) -> Dict[str, Any]:
|
|
|
|
| 382 |
}
|
| 383 |
|
| 384 |
except Exception as e:
|
| 385 |
+
logger.error(
|
| 386 |
+
f"Error getting service pricing summary: {e}",
|
| 387 |
+
extra={"operation": "get_service_pricing_summary", "merchant_id": merchant_id, "error": str(e)},
|
| 388 |
+
exc_info=True
|
| 389 |
+
)
|
| 390 |
raise
|
app/sync/customers/sync_service.py
CHANGED
|
@@ -5,13 +5,13 @@ Handles CRUD operations to keep PostgreSQL customer_ref table in sync with Mongo
|
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlalchemy import select, delete, text
|
| 7 |
from typing import Optional, Dict, Any
|
| 8 |
-
import logging
|
| 9 |
from datetime import datetime
|
|
|
|
| 10 |
|
| 11 |
from app.sync.models import CustomerRef
|
| 12 |
from app.customers.models.model import CustomerModel
|
| 13 |
|
| 14 |
-
logger =
|
| 15 |
|
| 16 |
|
| 17 |
class CustomerSyncService:
|
|
@@ -58,10 +58,13 @@ class CustomerSyncService:
|
|
| 58 |
"""
|
| 59 |
await self.pg_session.execute(text(create_table_sql))
|
| 60 |
await self.pg_session.commit()
|
| 61 |
-
logger.info("✅ Created customer_ref table in trans schema")
|
| 62 |
|
| 63 |
except Exception as e:
|
| 64 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 65 |
# Best-effort: if creation fails, proceed; operations may still succeed
|
| 66 |
|
| 67 |
def _extract_sync_data(self, customer: CustomerModel) -> Dict[str, Any]:
|
|
@@ -89,12 +92,19 @@ class CustomerSyncService:
|
|
| 89 |
await self.pg_session.commit()
|
| 90 |
await self.pg_session.refresh(customer_ref)
|
| 91 |
|
| 92 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 93 |
return customer_ref
|
| 94 |
|
| 95 |
except Exception as e:
|
| 96 |
await self.pg_session.rollback()
|
| 97 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
raise
|
| 99 |
|
| 100 |
async def update_customer_ref(self, customer_id: str, customer: CustomerModel) -> Optional[CustomerRef]:
|
|
@@ -108,7 +118,10 @@ class CustomerSyncService:
|
|
| 108 |
customer_ref = result.scalar_one_or_none()
|
| 109 |
|
| 110 |
if not customer_ref:
|
| 111 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 112 |
return None
|
| 113 |
|
| 114 |
# Update with new data
|
|
@@ -121,12 +134,19 @@ class CustomerSyncService:
|
|
| 121 |
await self.pg_session.commit()
|
| 122 |
await self.pg_session.refresh(customer_ref)
|
| 123 |
|
| 124 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 125 |
return customer_ref
|
| 126 |
|
| 127 |
except Exception as e:
|
| 128 |
await self.pg_session.rollback()
|
| 129 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
raise
|
| 131 |
|
| 132 |
async def delete_customer_ref(self, customer_id: str) -> bool:
|
|
@@ -140,15 +160,25 @@ class CustomerSyncService:
|
|
| 140 |
await self.pg_session.commit()
|
| 141 |
|
| 142 |
if deleted_count > 0:
|
| 143 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 144 |
return True
|
| 145 |
else:
|
| 146 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 147 |
return False
|
| 148 |
|
| 149 |
except Exception as e:
|
| 150 |
await self.pg_session.rollback()
|
| 151 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
raise
|
| 153 |
|
| 154 |
async def get_customer_ref(self, customer_id: str) -> Optional[CustomerRef]:
|
|
@@ -160,7 +190,11 @@ class CustomerSyncService:
|
|
| 160 |
return result.scalar_one_or_none()
|
| 161 |
|
| 162 |
except Exception as e:
|
| 163 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
raise
|
| 165 |
|
| 166 |
async def sync_customer_status(self, customer_id: str, status: str) -> bool:
|
|
@@ -175,15 +209,25 @@ class CustomerSyncService:
|
|
| 175 |
customer_ref.status = status
|
| 176 |
customer_ref.updated_at = datetime.utcnow()
|
| 177 |
await self.pg_session.commit()
|
| 178 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 179 |
return True
|
| 180 |
else:
|
| 181 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 182 |
return False
|
| 183 |
|
| 184 |
except Exception as e:
|
| 185 |
await self.pg_session.rollback()
|
| 186 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
raise
|
| 188 |
|
| 189 |
async def list_customers_by_merchant(self, merchant_id: str, limit: int = 100, offset: int = 0) -> list:
|
|
@@ -199,7 +243,11 @@ class CustomerSyncService:
|
|
| 199 |
return result.scalars().all()
|
| 200 |
|
| 201 |
except Exception as e:
|
| 202 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
raise
|
| 204 |
|
| 205 |
async def search_customers(self, merchant_id: str, search_term: str, limit: int = 50) -> list:
|
|
@@ -222,5 +270,9 @@ class CustomerSyncService:
|
|
| 222 |
return result.scalars().all()
|
| 223 |
|
| 224 |
except Exception as e:
|
| 225 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
raise
|
|
|
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlalchemy import select, delete, text
|
| 7 |
from typing import Optional, Dict, Any
|
|
|
|
| 8 |
from datetime import datetime
|
| 9 |
+
from app.core.logging import get_logger
|
| 10 |
|
| 11 |
from app.sync.models import CustomerRef
|
| 12 |
from app.customers.models.model import CustomerModel
|
| 13 |
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
|
| 16 |
|
| 17 |
class CustomerSyncService:
|
|
|
|
| 58 |
"""
|
| 59 |
await self.pg_session.execute(text(create_table_sql))
|
| 60 |
await self.pg_session.commit()
|
| 61 |
+
logger.info("✅ Created customer_ref table in trans schema", extra={"operation": "ensure_schema"})
|
| 62 |
|
| 63 |
except Exception as e:
|
| 64 |
+
logger.warning(
|
| 65 |
+
f"Schema/table creation failed (may already exist): {e}",
|
| 66 |
+
extra={"operation": "ensure_schema", "error": str(e)}
|
| 67 |
+
)
|
| 68 |
# Best-effort: if creation fails, proceed; operations may still succeed
|
| 69 |
|
| 70 |
def _extract_sync_data(self, customer: CustomerModel) -> Dict[str, Any]:
|
|
|
|
| 92 |
await self.pg_session.commit()
|
| 93 |
await self.pg_session.refresh(customer_ref)
|
| 94 |
|
| 95 |
+
logger.info(
|
| 96 |
+
f"Created customer reference: {customer.customer_id}",
|
| 97 |
+
extra={"operation": "create_customer_ref", "customer_id": customer.customer_id, "merchant_id": customer.merchant_id}
|
| 98 |
+
)
|
| 99 |
return customer_ref
|
| 100 |
|
| 101 |
except Exception as e:
|
| 102 |
await self.pg_session.rollback()
|
| 103 |
+
logger.error(
|
| 104 |
+
f"Error creating customer reference: {e}",
|
| 105 |
+
extra={"operation": "create_customer_ref", "customer_id": customer.customer_id, "error": str(e)},
|
| 106 |
+
exc_info=True
|
| 107 |
+
)
|
| 108 |
raise
|
| 109 |
|
| 110 |
async def update_customer_ref(self, customer_id: str, customer: CustomerModel) -> Optional[CustomerRef]:
|
|
|
|
| 118 |
customer_ref = result.scalar_one_or_none()
|
| 119 |
|
| 120 |
if not customer_ref:
|
| 121 |
+
logger.warning(
|
| 122 |
+
f"Customer reference not found for update: {customer_id}",
|
| 123 |
+
extra={"operation": "update_customer_ref", "customer_id": customer_id}
|
| 124 |
+
)
|
| 125 |
return None
|
| 126 |
|
| 127 |
# Update with new data
|
|
|
|
| 134 |
await self.pg_session.commit()
|
| 135 |
await self.pg_session.refresh(customer_ref)
|
| 136 |
|
| 137 |
+
logger.info(
|
| 138 |
+
f"Updated customer reference: {customer_id}",
|
| 139 |
+
extra={"operation": "update_customer_ref", "customer_id": customer_id, "merchant_id": customer.merchant_id}
|
| 140 |
+
)
|
| 141 |
return customer_ref
|
| 142 |
|
| 143 |
except Exception as e:
|
| 144 |
await self.pg_session.rollback()
|
| 145 |
+
logger.error(
|
| 146 |
+
f"Error updating customer reference: {e}",
|
| 147 |
+
extra={"operation": "update_customer_ref", "customer_id": customer_id, "error": str(e)},
|
| 148 |
+
exc_info=True
|
| 149 |
+
)
|
| 150 |
raise
|
| 151 |
|
| 152 |
async def delete_customer_ref(self, customer_id: str) -> bool:
|
|
|
|
| 160 |
await self.pg_session.commit()
|
| 161 |
|
| 162 |
if deleted_count > 0:
|
| 163 |
+
logger.info(
|
| 164 |
+
f"Deleted customer reference: {customer_id}",
|
| 165 |
+
extra={"operation": "delete_customer_ref", "customer_id": customer_id}
|
| 166 |
+
)
|
| 167 |
return True
|
| 168 |
else:
|
| 169 |
+
logger.warning(
|
| 170 |
+
f"Customer reference not found for deletion: {customer_id}",
|
| 171 |
+
extra={"operation": "delete_customer_ref", "customer_id": customer_id}
|
| 172 |
+
)
|
| 173 |
return False
|
| 174 |
|
| 175 |
except Exception as e:
|
| 176 |
await self.pg_session.rollback()
|
| 177 |
+
logger.error(
|
| 178 |
+
f"Error deleting customer reference: {e}",
|
| 179 |
+
extra={"operation": "delete_customer_ref", "customer_id": customer_id, "error": str(e)},
|
| 180 |
+
exc_info=True
|
| 181 |
+
)
|
| 182 |
raise
|
| 183 |
|
| 184 |
async def get_customer_ref(self, customer_id: str) -> Optional[CustomerRef]:
|
|
|
|
| 190 |
return result.scalar_one_or_none()
|
| 191 |
|
| 192 |
except Exception as e:
|
| 193 |
+
logger.error(
|
| 194 |
+
f"Error getting customer reference: {e}",
|
| 195 |
+
extra={"operation": "get_customer_ref", "customer_id": customer_id, "error": str(e)},
|
| 196 |
+
exc_info=True
|
| 197 |
+
)
|
| 198 |
raise
|
| 199 |
|
| 200 |
async def sync_customer_status(self, customer_id: str, status: str) -> bool:
|
|
|
|
| 209 |
customer_ref.status = status
|
| 210 |
customer_ref.updated_at = datetime.utcnow()
|
| 211 |
await self.pg_session.commit()
|
| 212 |
+
logger.info(
|
| 213 |
+
f"Updated customer status: {customer_id} -> {status}",
|
| 214 |
+
extra={"operation": "sync_customer_status", "customer_id": customer_id, "status": status}
|
| 215 |
+
)
|
| 216 |
return True
|
| 217 |
else:
|
| 218 |
+
logger.warning(
|
| 219 |
+
f"Customer reference not found for status update: {customer_id}",
|
| 220 |
+
extra={"operation": "sync_customer_status", "customer_id": customer_id}
|
| 221 |
+
)
|
| 222 |
return False
|
| 223 |
|
| 224 |
except Exception as e:
|
| 225 |
await self.pg_session.rollback()
|
| 226 |
+
logger.error(
|
| 227 |
+
f"Error updating customer status: {e}",
|
| 228 |
+
extra={"operation": "sync_customer_status", "customer_id": customer_id, "status": status, "error": str(e)},
|
| 229 |
+
exc_info=True
|
| 230 |
+
)
|
| 231 |
raise
|
| 232 |
|
| 233 |
async def list_customers_by_merchant(self, merchant_id: str, limit: int = 100, offset: int = 0) -> list:
|
|
|
|
| 243 |
return result.scalars().all()
|
| 244 |
|
| 245 |
except Exception as e:
|
| 246 |
+
logger.error(
|
| 247 |
+
f"Error listing customers by merchant: {e}",
|
| 248 |
+
extra={"operation": "list_customers_by_merchant", "merchant_id": merchant_id, "error": str(e)},
|
| 249 |
+
exc_info=True
|
| 250 |
+
)
|
| 251 |
raise
|
| 252 |
|
| 253 |
async def search_customers(self, merchant_id: str, search_term: str, limit: int = 50) -> list:
|
|
|
|
| 270 |
return result.scalars().all()
|
| 271 |
|
| 272 |
except Exception as e:
|
| 273 |
+
logger.error(
|
| 274 |
+
f"Error searching customers: {e}",
|
| 275 |
+
extra={"operation": "search_customers", "merchant_id": merchant_id, "search_term": search_term, "error": str(e)},
|
| 276 |
+
exc_info=True
|
| 277 |
+
)
|
| 278 |
raise
|
app/sync/staff/sync_service.py
CHANGED
|
@@ -5,12 +5,12 @@ Handles CRUD operations to keep PostgreSQL staff_ref table in sync with MongoDB.
|
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlalchemy import select, delete, text
|
| 7 |
from typing import Optional, Dict, Any, List
|
| 8 |
-
import logging
|
| 9 |
from datetime import datetime
|
|
|
|
| 10 |
|
| 11 |
from app.sync.models import StaffRef
|
| 12 |
|
| 13 |
-
logger =
|
| 14 |
|
| 15 |
|
| 16 |
class StaffSyncService:
|
|
@@ -57,10 +57,13 @@ class StaffSyncService:
|
|
| 57 |
"""
|
| 58 |
await self.pg_session.execute(text(create_table_sql))
|
| 59 |
await self.pg_session.commit()
|
| 60 |
-
logger.info("✅ Created pos_staff_ref table in trans schema")
|
| 61 |
|
| 62 |
except Exception as e:
|
| 63 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
def _extract_sync_data(self, staff_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 66 |
"""Extract relevant data for PostgreSQL sync"""
|
|
@@ -88,12 +91,19 @@ class StaffSyncService:
|
|
| 88 |
await self.pg_session.commit()
|
| 89 |
await self.pg_session.refresh(staff_ref)
|
| 90 |
|
| 91 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 92 |
return staff_ref
|
| 93 |
|
| 94 |
except Exception as e:
|
| 95 |
await self.pg_session.rollback()
|
| 96 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
raise
|
| 98 |
|
| 99 |
async def update_staff_ref(self, staff_id: str, staff_data: Dict[str, Any]) -> Optional[StaffRef]:
|
|
@@ -107,7 +117,10 @@ class StaffSyncService:
|
|
| 107 |
staff_ref = result.scalar_one_or_none()
|
| 108 |
|
| 109 |
if not staff_ref:
|
| 110 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 111 |
return None
|
| 112 |
|
| 113 |
# Update with new data
|
|
@@ -121,12 +134,19 @@ class StaffSyncService:
|
|
| 121 |
await self.pg_session.commit()
|
| 122 |
await self.pg_session.refresh(staff_ref)
|
| 123 |
|
| 124 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 125 |
return staff_ref
|
| 126 |
|
| 127 |
except Exception as e:
|
| 128 |
await self.pg_session.rollback()
|
| 129 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
raise
|
| 131 |
|
| 132 |
async def delete_staff_ref(self, staff_id: str) -> bool:
|
|
@@ -140,15 +160,25 @@ class StaffSyncService:
|
|
| 140 |
await self.pg_session.commit()
|
| 141 |
|
| 142 |
if deleted_count > 0:
|
| 143 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 144 |
return True
|
| 145 |
else:
|
| 146 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 147 |
return False
|
| 148 |
|
| 149 |
except Exception as e:
|
| 150 |
await self.pg_session.rollback()
|
| 151 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
raise
|
| 153 |
|
| 154 |
async def get_staff_ref(self, staff_id: str) -> Optional[StaffRef]:
|
|
@@ -160,7 +190,11 @@ class StaffSyncService:
|
|
| 160 |
return result.scalar_one_or_none()
|
| 161 |
|
| 162 |
except Exception as e:
|
| 163 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
raise
|
| 165 |
|
| 166 |
async def sync_staff_status(self, staff_id: str, status: str) -> bool:
|
|
@@ -175,15 +209,25 @@ class StaffSyncService:
|
|
| 175 |
staff_ref.status = status
|
| 176 |
staff_ref.updated_at = datetime.utcnow()
|
| 177 |
await self.pg_session.commit()
|
| 178 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 179 |
return True
|
| 180 |
else:
|
| 181 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 182 |
return False
|
| 183 |
|
| 184 |
except Exception as e:
|
| 185 |
await self.pg_session.rollback()
|
| 186 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
raise
|
| 188 |
|
| 189 |
async def list_staff_by_merchant(self, merchant_id: str, limit: int = 100, offset: int = 0) -> List[StaffRef]:
|
|
@@ -199,7 +243,11 @@ class StaffSyncService:
|
|
| 199 |
return result.scalars().all()
|
| 200 |
|
| 201 |
except Exception as e:
|
| 202 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
raise
|
| 204 |
|
| 205 |
async def search_staff(self, merchant_id: str, search_term: str, limit: int = 50) -> List[StaffRef]:
|
|
@@ -222,7 +270,11 @@ class StaffSyncService:
|
|
| 222 |
return result.scalars().all()
|
| 223 |
|
| 224 |
except Exception as e:
|
| 225 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
raise
|
| 227 |
|
| 228 |
async def list_staff_by_role(self, merchant_id: str, role: str) -> List[StaffRef]:
|
|
@@ -239,7 +291,11 @@ class StaffSyncService:
|
|
| 239 |
return result.scalars().all()
|
| 240 |
|
| 241 |
except Exception as e:
|
| 242 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
raise
|
| 244 |
|
| 245 |
async def list_staff_by_specialization(self, merchant_id: str, specialization: str) -> List[StaffRef]:
|
|
@@ -256,5 +312,9 @@ class StaffSyncService:
|
|
| 256 |
return result.scalars().all()
|
| 257 |
|
| 258 |
except Exception as e:
|
| 259 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
raise
|
|
|
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlalchemy import select, delete, text
|
| 7 |
from typing import Optional, Dict, Any, List
|
|
|
|
| 8 |
from datetime import datetime
|
| 9 |
+
from app.core.logging import get_logger
|
| 10 |
|
| 11 |
from app.sync.models import StaffRef
|
| 12 |
|
| 13 |
+
logger = get_logger(__name__)
|
| 14 |
|
| 15 |
|
| 16 |
class StaffSyncService:
|
|
|
|
| 57 |
"""
|
| 58 |
await self.pg_session.execute(text(create_table_sql))
|
| 59 |
await self.pg_session.commit()
|
| 60 |
+
logger.info("✅ Created pos_staff_ref table in trans schema", extra={"operation": "ensure_schema"})
|
| 61 |
|
| 62 |
except Exception as e:
|
| 63 |
+
logger.warning(
|
| 64 |
+
f"Schema/table creation failed (may already exist): {e}",
|
| 65 |
+
extra={"operation": "ensure_schema", "error": str(e)}
|
| 66 |
+
)
|
| 67 |
|
| 68 |
def _extract_sync_data(self, staff_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 69 |
"""Extract relevant data for PostgreSQL sync"""
|
|
|
|
| 91 |
await self.pg_session.commit()
|
| 92 |
await self.pg_session.refresh(staff_ref)
|
| 93 |
|
| 94 |
+
logger.info(
|
| 95 |
+
f"Created staff reference: {staff_data.get('staff_id')}",
|
| 96 |
+
extra={"operation": "create_staff_ref", "staff_id": staff_data.get('staff_id'), "merchant_id": staff_data.get('merchant_id')}
|
| 97 |
+
)
|
| 98 |
return staff_ref
|
| 99 |
|
| 100 |
except Exception as e:
|
| 101 |
await self.pg_session.rollback()
|
| 102 |
+
logger.error(
|
| 103 |
+
f"Error creating staff reference: {e}",
|
| 104 |
+
extra={"operation": "create_staff_ref", "staff_id": staff_data.get('staff_id'), "error": str(e)},
|
| 105 |
+
exc_info=True
|
| 106 |
+
)
|
| 107 |
raise
|
| 108 |
|
| 109 |
async def update_staff_ref(self, staff_id: str, staff_data: Dict[str, Any]) -> Optional[StaffRef]:
|
|
|
|
| 117 |
staff_ref = result.scalar_one_or_none()
|
| 118 |
|
| 119 |
if not staff_ref:
|
| 120 |
+
logger.warning(
|
| 121 |
+
f"Staff reference not found for update: {staff_id}",
|
| 122 |
+
extra={"operation": "update_staff_ref", "staff_id": staff_id}
|
| 123 |
+
)
|
| 124 |
return None
|
| 125 |
|
| 126 |
# Update with new data
|
|
|
|
| 134 |
await self.pg_session.commit()
|
| 135 |
await self.pg_session.refresh(staff_ref)
|
| 136 |
|
| 137 |
+
logger.info(
|
| 138 |
+
f"Updated staff reference: {staff_id}",
|
| 139 |
+
extra={"operation": "update_staff_ref", "staff_id": staff_id, "merchant_id": sync_data.get('merchant_id')}
|
| 140 |
+
)
|
| 141 |
return staff_ref
|
| 142 |
|
| 143 |
except Exception as e:
|
| 144 |
await self.pg_session.rollback()
|
| 145 |
+
logger.error(
|
| 146 |
+
f"Error updating staff reference: {e}",
|
| 147 |
+
extra={"operation": "update_staff_ref", "staff_id": staff_id, "error": str(e)},
|
| 148 |
+
exc_info=True
|
| 149 |
+
)
|
| 150 |
raise
|
| 151 |
|
| 152 |
async def delete_staff_ref(self, staff_id: str) -> bool:
|
|
|
|
| 160 |
await self.pg_session.commit()
|
| 161 |
|
| 162 |
if deleted_count > 0:
|
| 163 |
+
logger.info(
|
| 164 |
+
f"Deleted staff reference: {staff_id}",
|
| 165 |
+
extra={"operation": "delete_staff_ref", "staff_id": staff_id}
|
| 166 |
+
)
|
| 167 |
return True
|
| 168 |
else:
|
| 169 |
+
logger.warning(
|
| 170 |
+
f"Staff reference not found for deletion: {staff_id}",
|
| 171 |
+
extra={"operation": "delete_staff_ref", "staff_id": staff_id}
|
| 172 |
+
)
|
| 173 |
return False
|
| 174 |
|
| 175 |
except Exception as e:
|
| 176 |
await self.pg_session.rollback()
|
| 177 |
+
logger.error(
|
| 178 |
+
f"Error deleting staff reference: {e}",
|
| 179 |
+
extra={"operation": "delete_staff_ref", "staff_id": staff_id, "error": str(e)},
|
| 180 |
+
exc_info=True
|
| 181 |
+
)
|
| 182 |
raise
|
| 183 |
|
| 184 |
async def get_staff_ref(self, staff_id: str) -> Optional[StaffRef]:
|
|
|
|
| 190 |
return result.scalar_one_or_none()
|
| 191 |
|
| 192 |
except Exception as e:
|
| 193 |
+
logger.error(
|
| 194 |
+
f"Error getting staff reference: {e}",
|
| 195 |
+
extra={"operation": "get_staff_ref", "staff_id": staff_id, "error": str(e)},
|
| 196 |
+
exc_info=True
|
| 197 |
+
)
|
| 198 |
raise
|
| 199 |
|
| 200 |
async def sync_staff_status(self, staff_id: str, status: str) -> bool:
|
|
|
|
| 209 |
staff_ref.status = status
|
| 210 |
staff_ref.updated_at = datetime.utcnow()
|
| 211 |
await self.pg_session.commit()
|
| 212 |
+
logger.info(
|
| 213 |
+
f"Updated staff status: {staff_id} -> {status}",
|
| 214 |
+
extra={"operation": "sync_staff_status", "staff_id": staff_id, "status": status}
|
| 215 |
+
)
|
| 216 |
return True
|
| 217 |
else:
|
| 218 |
+
logger.warning(
|
| 219 |
+
f"Staff reference not found for status update: {staff_id}",
|
| 220 |
+
extra={"operation": "sync_staff_status", "staff_id": staff_id}
|
| 221 |
+
)
|
| 222 |
return False
|
| 223 |
|
| 224 |
except Exception as e:
|
| 225 |
await self.pg_session.rollback()
|
| 226 |
+
logger.error(
|
| 227 |
+
f"Error updating staff status: {e}",
|
| 228 |
+
extra={"operation": "sync_staff_status", "staff_id": staff_id, "status": status, "error": str(e)},
|
| 229 |
+
exc_info=True
|
| 230 |
+
)
|
| 231 |
raise
|
| 232 |
|
| 233 |
async def list_staff_by_merchant(self, merchant_id: str, limit: int = 100, offset: int = 0) -> List[StaffRef]:
|
|
|
|
| 243 |
return result.scalars().all()
|
| 244 |
|
| 245 |
except Exception as e:
|
| 246 |
+
logger.error(
|
| 247 |
+
f"Error listing staff by merchant: {e}",
|
| 248 |
+
extra={"operation": "list_staff_by_merchant", "merchant_id": merchant_id, "error": str(e)},
|
| 249 |
+
exc_info=True
|
| 250 |
+
)
|
| 251 |
raise
|
| 252 |
|
| 253 |
async def search_staff(self, merchant_id: str, search_term: str, limit: int = 50) -> List[StaffRef]:
|
|
|
|
| 270 |
return result.scalars().all()
|
| 271 |
|
| 272 |
except Exception as e:
|
| 273 |
+
logger.error(
|
| 274 |
+
f"Error searching staff: {e}",
|
| 275 |
+
extra={"operation": "search_staff", "merchant_id": merchant_id, "search_term": search_term, "error": str(e)},
|
| 276 |
+
exc_info=True
|
| 277 |
+
)
|
| 278 |
raise
|
| 279 |
|
| 280 |
async def list_staff_by_role(self, merchant_id: str, role: str) -> List[StaffRef]:
|
|
|
|
| 291 |
return result.scalars().all()
|
| 292 |
|
| 293 |
except Exception as e:
|
| 294 |
+
logger.error(
|
| 295 |
+
f"Error listing staff by role: {e}",
|
| 296 |
+
extra={"operation": "list_staff_by_role", "merchant_id": merchant_id, "role": role, "error": str(e)},
|
| 297 |
+
exc_info=True
|
| 298 |
+
)
|
| 299 |
raise
|
| 300 |
|
| 301 |
async def list_staff_by_specialization(self, merchant_id: str, specialization: str) -> List[StaffRef]:
|
|
|
|
| 312 |
return result.scalars().all()
|
| 313 |
|
| 314 |
except Exception as e:
|
| 315 |
+
logger.error(
|
| 316 |
+
f"Error listing staff by specialization: {e}",
|
| 317 |
+
extra={"operation": "list_staff_by_specialization", "merchant_id": merchant_id, "specialization": specialization, "error": str(e)},
|
| 318 |
+
exc_info=True
|
| 319 |
+
)
|
| 320 |
raise
|
app/sync/sync_service.py
CHANGED
|
@@ -4,14 +4,14 @@ Coordinates all sync operations across different entities.
|
|
| 4 |
"""
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from typing import Dict, Any, Optional
|
| 7 |
-
import
|
| 8 |
-
|
| 9 |
from app.sync.customers.sync_service import CustomerSyncService
|
| 10 |
from app.sync.staff.sync_service import StaffSyncService
|
| 11 |
from app.sync.catalogue_services.sync_service import CatalogueServiceSyncService
|
| 12 |
from app.customers.models.model import CustomerModel
|
| 13 |
|
| 14 |
-
logger =
|
| 15 |
|
| 16 |
|
| 17 |
class POSSyncService:
|
|
@@ -29,7 +29,16 @@ class POSSyncService:
|
|
| 29 |
try:
|
| 30 |
return await self.customer_sync.create_customer_ref(customer)
|
| 31 |
except Exception as e:
|
| 32 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
raise
|
| 34 |
|
| 35 |
async def sync_customer_update(self, customer_id: str, customer: CustomerModel):
|
|
@@ -37,7 +46,15 @@ class POSSyncService:
|
|
| 37 |
try:
|
| 38 |
return await self.customer_sync.update_customer_ref(customer_id, customer)
|
| 39 |
except Exception as e:
|
| 40 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
raise
|
| 42 |
|
| 43 |
async def sync_customer_delete(self, customer_id: str):
|
|
@@ -45,7 +62,15 @@ class POSSyncService:
|
|
| 45 |
try:
|
| 46 |
return await self.customer_sync.delete_customer_ref(customer_id)
|
| 47 |
except Exception as e:
|
| 48 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
raise
|
| 50 |
|
| 51 |
async def sync_customer_status(self, customer_id: str, status: str):
|
|
@@ -53,7 +78,16 @@ class POSSyncService:
|
|
| 53 |
try:
|
| 54 |
return await self.customer_sync.sync_customer_status(customer_id, status)
|
| 55 |
except Exception as e:
|
| 56 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
raise
|
| 58 |
|
| 59 |
# Staff sync operations
|
|
@@ -62,7 +96,16 @@ class POSSyncService:
|
|
| 62 |
try:
|
| 63 |
return await self.staff_sync.create_staff_ref(staff_data)
|
| 64 |
except Exception as e:
|
| 65 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
raise
|
| 67 |
|
| 68 |
async def sync_staff_update(self, staff_id: str, staff_data: Dict[str, Any]):
|
|
@@ -70,7 +113,15 @@ class POSSyncService:
|
|
| 70 |
try:
|
| 71 |
return await self.staff_sync.update_staff_ref(staff_id, staff_data)
|
| 72 |
except Exception as e:
|
| 73 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
raise
|
| 75 |
|
| 76 |
async def sync_staff_delete(self, staff_id: str):
|
|
@@ -78,7 +129,15 @@ class POSSyncService:
|
|
| 78 |
try:
|
| 79 |
return await self.staff_sync.delete_staff_ref(staff_id)
|
| 80 |
except Exception as e:
|
| 81 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
raise
|
| 83 |
|
| 84 |
async def sync_staff_status(self, staff_id: str, status: str):
|
|
@@ -86,7 +145,16 @@ class POSSyncService:
|
|
| 86 |
try:
|
| 87 |
return await self.staff_sync.sync_staff_status(staff_id, status)
|
| 88 |
except Exception as e:
|
| 89 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
raise
|
| 91 |
|
| 92 |
# Catalogue service sync operations
|
|
@@ -95,7 +163,16 @@ class POSSyncService:
|
|
| 95 |
try:
|
| 96 |
return await self.catalogue_service_sync.create_catalogue_service_ref(service_data)
|
| 97 |
except Exception as e:
|
| 98 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
raise
|
| 100 |
|
| 101 |
async def sync_catalogue_service_update(self, service_id: str, service_data: Dict[str, Any]):
|
|
@@ -103,7 +180,15 @@ class POSSyncService:
|
|
| 103 |
try:
|
| 104 |
return await self.catalogue_service_sync.update_catalogue_service_ref(service_id, service_data)
|
| 105 |
except Exception as e:
|
| 106 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
raise
|
| 108 |
|
| 109 |
async def sync_catalogue_service_delete(self, service_id: str):
|
|
@@ -111,7 +196,15 @@ class POSSyncService:
|
|
| 111 |
try:
|
| 112 |
return await self.catalogue_service_sync.delete_catalogue_service_ref(service_id)
|
| 113 |
except Exception as e:
|
| 114 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
raise
|
| 116 |
|
| 117 |
async def sync_catalogue_service_status(self, service_id: str, status: str):
|
|
@@ -119,7 +212,16 @@ class POSSyncService:
|
|
| 119 |
try:
|
| 120 |
return await self.catalogue_service_sync.sync_catalogue_service_status(service_id, status)
|
| 121 |
except Exception as e:
|
| 122 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
raise
|
| 124 |
|
| 125 |
# Bulk sync operations
|
|
@@ -131,7 +233,16 @@ class POSSyncService:
|
|
| 131 |
result = await self.customer_sync.create_customer_ref(customer)
|
| 132 |
results.append({"customer_id": customer.customer_id, "status": "success", "result": result})
|
| 133 |
except Exception as e:
|
| 134 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
results.append({"customer_id": customer.customer_id, "status": "error", "error": str(e)})
|
| 136 |
return results
|
| 137 |
|
|
@@ -143,7 +254,16 @@ class POSSyncService:
|
|
| 143 |
result = await self.staff_sync.create_staff_ref(staff_data)
|
| 144 |
results.append({"staff_id": staff_data.get('staff_id'), "status": "success", "result": result})
|
| 145 |
except Exception as e:
|
| 146 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
results.append({"staff_id": staff_data.get('staff_id'), "status": "error", "error": str(e)})
|
| 148 |
return results
|
| 149 |
|
|
@@ -155,7 +275,16 @@ class POSSyncService:
|
|
| 155 |
result = await self.catalogue_service_sync.create_catalogue_service_ref(service_data)
|
| 156 |
results.append({"service_id": service_data.get('_id') or service_data.get('service_id'), "status": "success", "result": result})
|
| 157 |
except Exception as e:
|
| 158 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
results.append({"service_id": service_data.get('_id') or service_data.get('service_id'), "status": "error", "error": str(e)})
|
| 160 |
return results
|
| 161 |
|
|
@@ -169,7 +298,11 @@ class POSSyncService:
|
|
| 169 |
await self.customer_sync._ensure_customer_ref_schema()
|
| 170 |
except Exception as e:
|
| 171 |
customer_health = False
|
| 172 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
# Test staff sync
|
| 175 |
staff_health = True
|
|
@@ -177,7 +310,11 @@ class POSSyncService:
|
|
| 177 |
await self.staff_sync._ensure_staff_ref_schema()
|
| 178 |
except Exception as e:
|
| 179 |
staff_health = False
|
| 180 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
# Test catalogue service sync
|
| 183 |
catalogue_service_health = True
|
|
@@ -185,18 +322,27 @@ class POSSyncService:
|
|
| 185 |
await self.catalogue_service_sync._ensure_catalogue_service_ref_schema()
|
| 186 |
except Exception as e:
|
| 187 |
catalogue_service_health = False
|
| 188 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
|
|
|
| 190 |
return {
|
| 191 |
"overall_health": customer_health and staff_health and catalogue_service_health,
|
| 192 |
"customer_sync": customer_health,
|
| 193 |
"staff_sync": staff_health,
|
| 194 |
"catalogue_service_sync": catalogue_service_health,
|
| 195 |
-
"timestamp":
|
| 196 |
}
|
| 197 |
|
| 198 |
except Exception as e:
|
| 199 |
-
logger.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
return {
|
| 201 |
"overall_health": False,
|
| 202 |
"error": str(e)
|
|
|
|
| 4 |
"""
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from typing import Dict, Any, Optional
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from app.core.logging import get_logger
|
| 9 |
from app.sync.customers.sync_service import CustomerSyncService
|
| 10 |
from app.sync.staff.sync_service import StaffSyncService
|
| 11 |
from app.sync.catalogue_services.sync_service import CatalogueServiceSyncService
|
| 12 |
from app.customers.models.model import CustomerModel
|
| 13 |
|
| 14 |
+
logger = get_logger(__name__)
|
| 15 |
|
| 16 |
|
| 17 |
class POSSyncService:
|
|
|
|
| 29 |
try:
|
| 30 |
return await self.customer_sync.create_customer_ref(customer)
|
| 31 |
except Exception as e:
|
| 32 |
+
logger.error(
|
| 33 |
+
f"Error syncing customer creation: {e}",
|
| 34 |
+
extra={
|
| 35 |
+
"operation": "sync_customer_create",
|
| 36 |
+
"customer_id": customer.customer_id,
|
| 37 |
+
"merchant_id": customer.merchant_id,
|
| 38 |
+
"error": str(e)
|
| 39 |
+
},
|
| 40 |
+
exc_info=True
|
| 41 |
+
)
|
| 42 |
raise
|
| 43 |
|
| 44 |
async def sync_customer_update(self, customer_id: str, customer: CustomerModel):
|
|
|
|
| 46 |
try:
|
| 47 |
return await self.customer_sync.update_customer_ref(customer_id, customer)
|
| 48 |
except Exception as e:
|
| 49 |
+
logger.error(
|
| 50 |
+
f"Error syncing customer update: {e}",
|
| 51 |
+
extra={
|
| 52 |
+
"operation": "sync_customer_update",
|
| 53 |
+
"customer_id": customer_id,
|
| 54 |
+
"error": str(e)
|
| 55 |
+
},
|
| 56 |
+
exc_info=True
|
| 57 |
+
)
|
| 58 |
raise
|
| 59 |
|
| 60 |
async def sync_customer_delete(self, customer_id: str):
|
|
|
|
| 62 |
try:
|
| 63 |
return await self.customer_sync.delete_customer_ref(customer_id)
|
| 64 |
except Exception as e:
|
| 65 |
+
logger.error(
|
| 66 |
+
f"Error syncing customer deletion: {e}",
|
| 67 |
+
extra={
|
| 68 |
+
"operation": "sync_customer_delete",
|
| 69 |
+
"customer_id": customer_id,
|
| 70 |
+
"error": str(e)
|
| 71 |
+
},
|
| 72 |
+
exc_info=True
|
| 73 |
+
)
|
| 74 |
raise
|
| 75 |
|
| 76 |
async def sync_customer_status(self, customer_id: str, status: str):
|
|
|
|
| 78 |
try:
|
| 79 |
return await self.customer_sync.sync_customer_status(customer_id, status)
|
| 80 |
except Exception as e:
|
| 81 |
+
logger.error(
|
| 82 |
+
f"Error syncing customer status: {e}",
|
| 83 |
+
extra={
|
| 84 |
+
"operation": "sync_customer_status",
|
| 85 |
+
"customer_id": customer_id,
|
| 86 |
+
"status": status,
|
| 87 |
+
"error": str(e)
|
| 88 |
+
},
|
| 89 |
+
exc_info=True
|
| 90 |
+
)
|
| 91 |
raise
|
| 92 |
|
| 93 |
# Staff sync operations
|
|
|
|
| 96 |
try:
|
| 97 |
return await self.staff_sync.create_staff_ref(staff_data)
|
| 98 |
except Exception as e:
|
| 99 |
+
logger.error(
|
| 100 |
+
f"Error syncing staff creation: {e}",
|
| 101 |
+
extra={
|
| 102 |
+
"operation": "sync_staff_create",
|
| 103 |
+
"staff_id": staff_data.get('staff_id'),
|
| 104 |
+
"merchant_id": staff_data.get('merchant_id'),
|
| 105 |
+
"error": str(e)
|
| 106 |
+
},
|
| 107 |
+
exc_info=True
|
| 108 |
+
)
|
| 109 |
raise
|
| 110 |
|
| 111 |
async def sync_staff_update(self, staff_id: str, staff_data: Dict[str, Any]):
|
|
|
|
| 113 |
try:
|
| 114 |
return await self.staff_sync.update_staff_ref(staff_id, staff_data)
|
| 115 |
except Exception as e:
|
| 116 |
+
logger.error(
|
| 117 |
+
f"Error syncing staff update: {e}",
|
| 118 |
+
extra={
|
| 119 |
+
"operation": "sync_staff_update",
|
| 120 |
+
"staff_id": staff_id,
|
| 121 |
+
"error": str(e)
|
| 122 |
+
},
|
| 123 |
+
exc_info=True
|
| 124 |
+
)
|
| 125 |
raise
|
| 126 |
|
| 127 |
async def sync_staff_delete(self, staff_id: str):
|
|
|
|
| 129 |
try:
|
| 130 |
return await self.staff_sync.delete_staff_ref(staff_id)
|
| 131 |
except Exception as e:
|
| 132 |
+
logger.error(
|
| 133 |
+
f"Error syncing staff deletion: {e}",
|
| 134 |
+
extra={
|
| 135 |
+
"operation": "sync_staff_delete",
|
| 136 |
+
"staff_id": staff_id,
|
| 137 |
+
"error": str(e)
|
| 138 |
+
},
|
| 139 |
+
exc_info=True
|
| 140 |
+
)
|
| 141 |
raise
|
| 142 |
|
| 143 |
async def sync_staff_status(self, staff_id: str, status: str):
|
|
|
|
| 145 |
try:
|
| 146 |
return await self.staff_sync.sync_staff_status(staff_id, status)
|
| 147 |
except Exception as e:
|
| 148 |
+
logger.error(
|
| 149 |
+
f"Error syncing staff status: {e}",
|
| 150 |
+
extra={
|
| 151 |
+
"operation": "sync_staff_status",
|
| 152 |
+
"staff_id": staff_id,
|
| 153 |
+
"status": status,
|
| 154 |
+
"error": str(e)
|
| 155 |
+
},
|
| 156 |
+
exc_info=True
|
| 157 |
+
)
|
| 158 |
raise
|
| 159 |
|
| 160 |
# Catalogue service sync operations
|
|
|
|
| 163 |
try:
|
| 164 |
return await self.catalogue_service_sync.create_catalogue_service_ref(service_data)
|
| 165 |
except Exception as e:
|
| 166 |
+
logger.error(
|
| 167 |
+
f"Error syncing catalogue service creation: {e}",
|
| 168 |
+
extra={
|
| 169 |
+
"operation": "sync_catalogue_service_create",
|
| 170 |
+
"service_id": service_data.get('_id') or service_data.get('service_id'),
|
| 171 |
+
"merchant_id": service_data.get('merchant_id'),
|
| 172 |
+
"error": str(e)
|
| 173 |
+
},
|
| 174 |
+
exc_info=True
|
| 175 |
+
)
|
| 176 |
raise
|
| 177 |
|
| 178 |
async def sync_catalogue_service_update(self, service_id: str, service_data: Dict[str, Any]):
|
|
|
|
| 180 |
try:
|
| 181 |
return await self.catalogue_service_sync.update_catalogue_service_ref(service_id, service_data)
|
| 182 |
except Exception as e:
|
| 183 |
+
logger.error(
|
| 184 |
+
f"Error syncing catalogue service update: {e}",
|
| 185 |
+
extra={
|
| 186 |
+
"operation": "sync_catalogue_service_update",
|
| 187 |
+
"service_id": service_id,
|
| 188 |
+
"error": str(e)
|
| 189 |
+
},
|
| 190 |
+
exc_info=True
|
| 191 |
+
)
|
| 192 |
raise
|
| 193 |
|
| 194 |
async def sync_catalogue_service_delete(self, service_id: str):
|
|
|
|
| 196 |
try:
|
| 197 |
return await self.catalogue_service_sync.delete_catalogue_service_ref(service_id)
|
| 198 |
except Exception as e:
|
| 199 |
+
logger.error(
|
| 200 |
+
f"Error syncing catalogue service deletion: {e}",
|
| 201 |
+
extra={
|
| 202 |
+
"operation": "sync_catalogue_service_delete",
|
| 203 |
+
"service_id": service_id,
|
| 204 |
+
"error": str(e)
|
| 205 |
+
},
|
| 206 |
+
exc_info=True
|
| 207 |
+
)
|
| 208 |
raise
|
| 209 |
|
| 210 |
async def sync_catalogue_service_status(self, service_id: str, status: str):
|
|
|
|
| 212 |
try:
|
| 213 |
return await self.catalogue_service_sync.sync_catalogue_service_status(service_id, status)
|
| 214 |
except Exception as e:
|
| 215 |
+
logger.error(
|
| 216 |
+
f"Error syncing catalogue service status: {e}",
|
| 217 |
+
extra={
|
| 218 |
+
"operation": "sync_catalogue_service_status",
|
| 219 |
+
"service_id": service_id,
|
| 220 |
+
"status": status,
|
| 221 |
+
"error": str(e)
|
| 222 |
+
},
|
| 223 |
+
exc_info=True
|
| 224 |
+
)
|
| 225 |
raise
|
| 226 |
|
| 227 |
# Bulk sync operations
|
|
|
|
| 233 |
result = await self.customer_sync.create_customer_ref(customer)
|
| 234 |
results.append({"customer_id": customer.customer_id, "status": "success", "result": result})
|
| 235 |
except Exception as e:
|
| 236 |
+
logger.error(
|
| 237 |
+
f"Error bulk syncing customer {customer.customer_id}: {e}",
|
| 238 |
+
extra={
|
| 239 |
+
"operation": "bulk_sync_customers",
|
| 240 |
+
"customer_id": customer.customer_id,
|
| 241 |
+
"merchant_id": customer.merchant_id,
|
| 242 |
+
"error": str(e)
|
| 243 |
+
},
|
| 244 |
+
exc_info=True
|
| 245 |
+
)
|
| 246 |
results.append({"customer_id": customer.customer_id, "status": "error", "error": str(e)})
|
| 247 |
return results
|
| 248 |
|
|
|
|
| 254 |
result = await self.staff_sync.create_staff_ref(staff_data)
|
| 255 |
results.append({"staff_id": staff_data.get('staff_id'), "status": "success", "result": result})
|
| 256 |
except Exception as e:
|
| 257 |
+
logger.error(
|
| 258 |
+
f"Error bulk syncing staff {staff_data.get('staff_id')}: {e}",
|
| 259 |
+
extra={
|
| 260 |
+
"operation": "bulk_sync_staff",
|
| 261 |
+
"staff_id": staff_data.get('staff_id'),
|
| 262 |
+
"merchant_id": staff_data.get('merchant_id'),
|
| 263 |
+
"error": str(e)
|
| 264 |
+
},
|
| 265 |
+
exc_info=True
|
| 266 |
+
)
|
| 267 |
results.append({"staff_id": staff_data.get('staff_id'), "status": "error", "error": str(e)})
|
| 268 |
return results
|
| 269 |
|
|
|
|
| 275 |
result = await self.catalogue_service_sync.create_catalogue_service_ref(service_data)
|
| 276 |
results.append({"service_id": service_data.get('_id') or service_data.get('service_id'), "status": "success", "result": result})
|
| 277 |
except Exception as e:
|
| 278 |
+
logger.error(
|
| 279 |
+
f"Error bulk syncing catalogue service {service_data.get('_id') or service_data.get('service_id')}: {e}",
|
| 280 |
+
extra={
|
| 281 |
+
"operation": "bulk_sync_catalogue_services",
|
| 282 |
+
"service_id": service_data.get('_id') or service_data.get('service_id'),
|
| 283 |
+
"merchant_id": service_data.get('merchant_id'),
|
| 284 |
+
"error": str(e)
|
| 285 |
+
},
|
| 286 |
+
exc_info=True
|
| 287 |
+
)
|
| 288 |
results.append({"service_id": service_data.get('_id') or service_data.get('service_id'), "status": "error", "error": str(e)})
|
| 289 |
return results
|
| 290 |
|
|
|
|
| 298 |
await self.customer_sync._ensure_customer_ref_schema()
|
| 299 |
except Exception as e:
|
| 300 |
customer_health = False
|
| 301 |
+
logger.error(
|
| 302 |
+
f"Customer sync health check failed: {e}",
|
| 303 |
+
extra={"operation": "check_sync_health", "component": "customer_sync", "error": str(e)},
|
| 304 |
+
exc_info=True
|
| 305 |
+
)
|
| 306 |
|
| 307 |
# Test staff sync
|
| 308 |
staff_health = True
|
|
|
|
| 310 |
await self.staff_sync._ensure_staff_ref_schema()
|
| 311 |
except Exception as e:
|
| 312 |
staff_health = False
|
| 313 |
+
logger.error(
|
| 314 |
+
f"Staff sync health check failed: {e}",
|
| 315 |
+
extra={"operation": "check_sync_health", "component": "staff_sync", "error": str(e)},
|
| 316 |
+
exc_info=True
|
| 317 |
+
)
|
| 318 |
|
| 319 |
# Test catalogue service sync
|
| 320 |
catalogue_service_health = True
|
|
|
|
| 322 |
await self.catalogue_service_sync._ensure_catalogue_service_ref_schema()
|
| 323 |
except Exception as e:
|
| 324 |
catalogue_service_health = False
|
| 325 |
+
logger.error(
|
| 326 |
+
f"Catalogue service sync health check failed: {e}",
|
| 327 |
+
extra={"operation": "check_sync_health", "component": "catalogue_service_sync", "error": str(e)},
|
| 328 |
+
exc_info=True
|
| 329 |
+
)
|
| 330 |
|
| 331 |
+
logger.info("Sync health check completed", extra={"operation": "check_sync_health"})
|
| 332 |
return {
|
| 333 |
"overall_health": customer_health and staff_health and catalogue_service_health,
|
| 334 |
"customer_sync": customer_health,
|
| 335 |
"staff_sync": staff_health,
|
| 336 |
"catalogue_service_sync": catalogue_service_health,
|
| 337 |
+
"timestamp": datetime.utcnow()
|
| 338 |
}
|
| 339 |
|
| 340 |
except Exception as e:
|
| 341 |
+
logger.error(
|
| 342 |
+
f"Error checking sync health: {e}",
|
| 343 |
+
extra={"operation": "check_sync_health", "error": str(e)},
|
| 344 |
+
exc_info=True
|
| 345 |
+
)
|
| 346 |
return {
|
| 347 |
"overall_health": False,
|
| 348 |
"error": str(e)
|
logger_expection.md
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logger & Error Handling Implementation Summary
|
| 2 |
+
|
| 3 |
+
## Quick Reference for New Modules
|
| 4 |
+
|
| 5 |
+
### 1. Logger Setup (One line per module)
|
| 6 |
+
```python
|
| 7 |
+
from app.core.logging import get_logger
|
| 8 |
+
|
| 9 |
+
logger = get_logger(__name__)
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Error Handling Patterns
|
| 15 |
+
|
| 16 |
+
### Pattern 1: Simple Error with Context
|
| 17 |
+
```python
|
| 18 |
+
try:
|
| 19 |
+
result = await operation()
|
| 20 |
+
except SpecificException as e:
|
| 21 |
+
logger.error(
|
| 22 |
+
"Operation failed",
|
| 23 |
+
extra={
|
| 24 |
+
"operation": "operation_name",
|
| 25 |
+
"error": str(e),
|
| 26 |
+
"error_type": type(e).__name__
|
| 27 |
+
},
|
| 28 |
+
exc_info=True
|
| 29 |
+
)
|
| 30 |
+
raise HTTPException(
|
| 31 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 32 |
+
detail="Operation failed"
|
| 33 |
+
)
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### Pattern 2: Validation Error with Details
|
| 37 |
+
```python
|
| 38 |
+
if not email or "@" not in email:
|
| 39 |
+
logger.warning(
|
| 40 |
+
"Validation failed",
|
| 41 |
+
extra={
|
| 42 |
+
"field": "email",
|
| 43 |
+
"value_provided": bool(email),
|
| 44 |
+
"validation": "email_format",
|
| 45 |
+
"user_id": user_id
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 50 |
+
detail="Invalid email format"
|
| 51 |
+
)
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### Pattern 3: Authentication Error
|
| 55 |
+
```python
|
| 56 |
+
if not token:
|
| 57 |
+
logger.warning(
|
| 58 |
+
"Authentication failed",
|
| 59 |
+
extra={
|
| 60 |
+
"reason": "missing_token",
|
| 61 |
+
"endpoint": request.url.path,
|
| 62 |
+
"client_ip": request.client.host if request.client else None
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
raise HTTPException(
|
| 66 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 67 |
+
detail="Missing authentication token"
|
| 68 |
+
)
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### Pattern 4: Permission Denied
|
| 72 |
+
```python
|
| 73 |
+
if user.role not in required_roles:
|
| 74 |
+
logger.warning(
|
| 75 |
+
"Access denied",
|
| 76 |
+
extra={
|
| 77 |
+
"user_id": str(user.id),
|
| 78 |
+
"user_role": user.role,
|
| 79 |
+
"required_role": required_roles,
|
| 80 |
+
"resource": request.url.path
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 85 |
+
detail="Insufficient permissions"
|
| 86 |
+
)
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### Pattern 5: Database Error
|
| 90 |
+
```python
|
| 91 |
+
try:
|
| 92 |
+
result = await collection.insert_one(data)
|
| 93 |
+
except PyMongoError as e:
|
| 94 |
+
logger.error(
|
| 95 |
+
"Database operation failed",
|
| 96 |
+
extra={
|
| 97 |
+
"operation": "insert_one",
|
| 98 |
+
"collection": "collection_name",
|
| 99 |
+
"error": str(e),
|
| 100 |
+
"error_type": type(e).__name__
|
| 101 |
+
},
|
| 102 |
+
exc_info=True
|
| 103 |
+
)
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 106 |
+
detail="Database operation failed"
|
| 107 |
+
)
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### Pattern 6: Success with Context
|
| 111 |
+
```python
|
| 112 |
+
logger.info(
|
| 113 |
+
"User login successful",
|
| 114 |
+
extra={
|
| 115 |
+
"user_id": user.id,
|
| 116 |
+
"username": user.username,
|
| 117 |
+
"method": "password",
|
| 118 |
+
"ip_address": request.client.host
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## Global Exception Handlers (in main.py)
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
from fastapi import FastAPI
|
| 129 |
+
from fastapi.exceptions import RequestValidationError
|
| 130 |
+
from pydantic import ValidationError
|
| 131 |
+
from jose import JWTError
|
| 132 |
+
from pymongo.errors import PyMongoError, ConnectionFailure, OperationFailure
|
| 133 |
+
|
| 134 |
+
# 1. Request Validation Errors
|
| 135 |
+
@app.exception_handler(RequestValidationError)
|
| 136 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
| 137 |
+
errors = [
|
| 138 |
+
{
|
| 139 |
+
"field": " -> ".join(str(loc) for loc in error["loc"]),
|
| 140 |
+
"message": error["msg"],
|
| 141 |
+
"type": error["type"]
|
| 142 |
+
}
|
| 143 |
+
for error in exc.errors()
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
logger.warning(
|
| 147 |
+
"Validation error",
|
| 148 |
+
extra={
|
| 149 |
+
"path": request.url.path,
|
| 150 |
+
"method": request.method,
|
| 151 |
+
"error_count": len(errors),
|
| 152 |
+
"errors": errors
|
| 153 |
+
}
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
return JSONResponse(
|
| 157 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 158 |
+
content={
|
| 159 |
+
"success": False,
|
| 160 |
+
"error": "Validation Error",
|
| 161 |
+
"errors": errors
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# 2. JWT Errors
|
| 166 |
+
@app.exception_handler(JWTError)
|
| 167 |
+
async def jwt_exception_handler(request: Request, exc: JWTError):
|
| 168 |
+
logger.warning(
|
| 169 |
+
"JWT authentication failed",
|
| 170 |
+
extra={
|
| 171 |
+
"path": request.url.path,
|
| 172 |
+
"error": str(exc),
|
| 173 |
+
"client_ip": request.client.host if request.client else None
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
return JSONResponse(
|
| 178 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 179 |
+
content={
|
| 180 |
+
"success": False,
|
| 181 |
+
"error": "Unauthorized",
|
| 182 |
+
"detail": "Invalid or expired token"
|
| 183 |
+
}
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# 3. MongoDB Errors
|
| 187 |
+
@app.exception_handler(PyMongoError)
|
| 188 |
+
async def mongodb_exception_handler(request: Request, exc: PyMongoError):
|
| 189 |
+
logger.error(
|
| 190 |
+
"Database error",
|
| 191 |
+
extra={
|
| 192 |
+
"path": request.url.path,
|
| 193 |
+
"error": str(exc),
|
| 194 |
+
"error_type": type(exc).__name__
|
| 195 |
+
},
|
| 196 |
+
exc_info=True
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
if isinstance(exc, ConnectionFailure):
|
| 200 |
+
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
| 201 |
+
detail = "Database connection failed"
|
| 202 |
+
elif isinstance(exc, OperationFailure):
|
| 203 |
+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 204 |
+
detail = "Database operation failed"
|
| 205 |
+
else:
|
| 206 |
+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 207 |
+
detail = "Database error occurred"
|
| 208 |
+
|
| 209 |
+
return JSONResponse(
|
| 210 |
+
status_code=status_code,
|
| 211 |
+
content={
|
| 212 |
+
"success": False,
|
| 213 |
+
"error": "Database Error",
|
| 214 |
+
"detail": detail
|
| 215 |
+
}
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# 4. General Exception Handler
|
| 219 |
+
@app.exception_handler(Exception)
|
| 220 |
+
async def general_exception_handler(request: Request, exc: Exception):
|
| 221 |
+
logger.error(
|
| 222 |
+
"Unhandled exception",
|
| 223 |
+
extra={
|
| 224 |
+
"method": request.method,
|
| 225 |
+
"path": request.url.path,
|
| 226 |
+
"error": str(exc),
|
| 227 |
+
"error_type": type(exc).__name__,
|
| 228 |
+
"client_ip": request.client.host if request.client else None
|
| 229 |
+
},
|
| 230 |
+
exc_info=True
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
return JSONResponse(
|
| 234 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 235 |
+
content={
|
| 236 |
+
"success": False,
|
| 237 |
+
"error": "Internal Server Error",
|
| 238 |
+
"detail": "An unexpected error occurred"
|
| 239 |
+
}
|
| 240 |
+
)
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## Context Fields Reference
|
| 246 |
+
|
| 247 |
+
| Field | Usage | Examples |
|
| 248 |
+
|-------|-------|----------|
|
| 249 |
+
| `user_id` | User identifier | "usr_123" |
|
| 250 |
+
| `username` | Username | "john_doe" |
|
| 251 |
+
| `email` | Email address | "john@example.com" |
|
| 252 |
+
| `operation` | Action being performed | "insert_user", "delete_account" |
|
| 253 |
+
| `error` | Error message | str(exception) |
|
| 254 |
+
| `error_type` | Exception class | "ValidationError", "ConnectionFailure" |
|
| 255 |
+
| `status_code` | HTTP status | 400, 401, 403, 500 |
|
| 256 |
+
| `path` | Request path | "/api/users/login" |
|
| 257 |
+
| `method` | HTTP method | "POST", "GET", "PUT" |
|
| 258 |
+
| `client_ip` | Client IP | "192.168.1.1" |
|
| 259 |
+
| `reason` | Why it failed | "invalid_password", "missing_token" |
|
| 260 |
+
| `field` | Field name (validation) | "email", "password" |
|
| 261 |
+
| `required_role` | Expected role | "admin", "super_admin" |
|
| 262 |
+
| `user_role` | User's actual role | "user" |
|
| 263 |
+
| `collection` | MongoDB collection | "system_users" |
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## Response Format Template
|
| 268 |
+
|
| 269 |
+
```python
|
| 270 |
+
# Success Response
|
| 271 |
+
{
|
| 272 |
+
"success": True,
|
| 273 |
+
"data": {...},
|
| 274 |
+
"message": "Operation completed"
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
# Error Response
|
| 278 |
+
{
|
| 279 |
+
"success": False,
|
| 280 |
+
"error": "Error Type",
|
| 281 |
+
"detail": "Detailed message",
|
| 282 |
+
"errors": [...] # For validation errors
|
| 283 |
+
}
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
---
|
| 287 |
+
|
| 288 |
+
## Implementation Checklist for New Module
|
| 289 |
+
|
| 290 |
+
- [ ] Import logger: `from app.core.logging import get_logger`
|
| 291 |
+
- [ ] Initialize: `logger = get_logger(__name__)`
|
| 292 |
+
- [ ] Import exceptions: `from fastapi import HTTPException, status`
|
| 293 |
+
- [ ] Wrap operations in try-except
|
| 294 |
+
- [ ] Log warnings for user errors (401, 403, 422)
|
| 295 |
+
- [ ] Log errors for server errors (500, 503)
|
| 296 |
+
- [ ] Include error_type in extras
|
| 297 |
+
- [ ] Use exc_info=True for exceptions
|
| 298 |
+
- [ ] Include user_id for actions
|
| 299 |
+
- [ ] Include operation name for tracking
|
| 300 |
+
- [ ] Use consistent field names (see reference table)
|
| 301 |
+
|
| 302 |
+
---
|
| 303 |
+
|
| 304 |
+
## Common HTTP Status Codes
|
| 305 |
+
|
| 306 |
+
| Code | Meaning | Logger Level | Use Case |
|
| 307 |
+
|------|---------|--------------|----------|
|
| 308 |
+
| 400 | Bad Request | `warning` | Invalid input data |
|
| 309 |
+
| 401 | Unauthorized | `warning` | Missing/invalid token |
|
| 310 |
+
| 403 | Forbidden | `warning` | Insufficient permissions |
|
| 311 |
+
| 404 | Not Found | `info` | Resource doesn't exist |
|
| 312 |
+
| 422 | Validation Error | `warning` | Invalid field values |
|
| 313 |
+
| 500 | Server Error | `error` | Unexpected exception |
|
| 314 |
+
| 503 | Service Unavailable | `error` | DB connection failed |
|
| 315 |
+
|
| 316 |
+
---
|
| 317 |
+
|
| 318 |
+
## Quick Copy-Paste Template
|
| 319 |
+
|
| 320 |
+
```python
|
| 321 |
+
"""
|
| 322 |
+
Module description.
|
| 323 |
+
"""
|
| 324 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 325 |
+
from fastapi.responses import JSONResponse
|
| 326 |
+
|
| 327 |
+
from app.core.logging import get_logger
|
| 328 |
+
from app.system_users.services.service import SystemUserService
|
| 329 |
+
from app.dependencies.auth import get_system_user_service
|
| 330 |
+
|
| 331 |
+
logger = get_logger(__name__)
|
| 332 |
+
router = APIRouter(prefix="/api/path", tags=["Category"])
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@router.post("/endpoint")
|
| 336 |
+
async def endpoint_function(
|
| 337 |
+
data: RequestModel,
|
| 338 |
+
service: SystemUserService = Depends(get_system_user_service)
|
| 339 |
+
):
|
| 340 |
+
"""
|
| 341 |
+
Endpoint description.
|
| 342 |
+
|
| 343 |
+
Raises:
|
| 344 |
+
HTTPException: 400 - Invalid input
|
| 345 |
+
HTTPException: 401 - Unauthorized
|
| 346 |
+
HTTPException: 500 - Server error
|
| 347 |
+
"""
|
| 348 |
+
try:
|
| 349 |
+
# Validation
|
| 350 |
+
if not data.field:
|
| 351 |
+
logger.warning(
|
| 352 |
+
"Validation failed",
|
| 353 |
+
extra={
|
| 354 |
+
"field": "field_name",
|
| 355 |
+
"reason": "empty_value"
|
| 356 |
+
}
|
| 357 |
+
)
|
| 358 |
+
raise HTTPException(
|
| 359 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 360 |
+
detail="Field is required"
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
# Business logic
|
| 364 |
+
result = await service.operation(data)
|
| 365 |
+
|
| 366 |
+
# Success logging
|
| 367 |
+
logger.info(
|
| 368 |
+
"Operation successful",
|
| 369 |
+
extra={
|
| 370 |
+
"operation": "operation_name",
|
| 371 |
+
"result_id": result.id
|
| 372 |
+
}
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
return {
|
| 376 |
+
"success": True,
|
| 377 |
+
"data": result,
|
| 378 |
+
"message": "Operation completed"
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
except HTTPException:
|
| 382 |
+
raise
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(
|
| 385 |
+
"Operation failed",
|
| 386 |
+
extra={
|
| 387 |
+
"operation": "operation_name",
|
| 388 |
+
"error": str(e),
|
| 389 |
+
"error_type": type(e).__name__
|
| 390 |
+
},
|
| 391 |
+
exc_info=True
|
| 392 |
+
)
|
| 393 |
+
raise HTTPException(
|
| 394 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 395 |
+
detail="Operation failed"
|
| 396 |
+
)
|
| 397 |
+
```
|
| 398 |
+
|
| 399 |
+
|