MukeshKapoor25 commited on
Commit
cfd9177
Β·
1 Parent(s): a6fc76c

refactor(auth): Migrate from employees to staff module and consolidate constants

Browse files

- Remove auth router, auth logs, and role models from auth module
- Delete employees module and migrate functionality to staff module
- Add constants module with collections and employee types definitions
- Create catalogues router for product catalogue management
- Update database initialization to support new module structure
- Refactor authentication dependencies to work with new staff module
- Update main application router configuration for new module layout
- Consolidate employee-related schemas and models under staff namespace
- Simplify role management by removing dedicated role model
- Update test files to reflect new module organization and naming conventions

app/auth/controllers/router.py DELETED
@@ -1,457 +0,0 @@
1
- """
2
- Authentication router for system users.
3
- Provides login, logout, and token management endpoints.
4
- """
5
- from datetime import timedelta
6
- from typing import Optional, List, Dict
7
- from fastapi import APIRouter, Depends, HTTPException, status, Body
8
- # Removed OAuth2PasswordRequestForm due to compatibility issues
9
- from pydantic import BaseModel, EmailStr
10
-
11
- from app.system_users.services.service import SystemUserService
12
- from app.dependencies.auth import get_system_user_service, get_current_user
13
- from app.system_users.models.model import SystemUserModel
14
- # from insightfy_utils.logging import get_logger # TODO: Uncomment when package is available
15
- import logging
16
-
17
- # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
18
- logger = logging.getLogger(__name__)
19
-
20
- router = APIRouter(prefix="/auth", tags=["Authentication"])
21
-
22
-
23
- def _get_accessible_widgets(user_role) -> List[Dict]:
24
- """Generate accessible widgets based on user role."""
25
- # Base widgets available to all roles - Supply Chain focused
26
- base_widgets = [
27
- {
28
- "widget_id": "wid_orders_count_001",
29
- "widget_type": "kpi",
30
- "title": "Orders Count",
31
- "accessible": True
32
- },
33
- {
34
- "widget_id": "wid_pending_orders_001",
35
- "widget_type": "kpi",
36
- "title": "Pending Orders",
37
- "accessible": True
38
- },
39
- {
40
- "widget_id": "wid_stock_level_001",
41
- "widget_type": "kpi",
42
- "title": "Stock Level",
43
- "accessible": True
44
- },
45
- {
46
- "widget_id": "wid_supplier_count_001",
47
- "widget_type": "kpi",
48
- "title": "Active Suppliers",
49
- "accessible": True
50
- },
51
- {
52
- "widget_id": "wid_inventory_status_001",
53
- "widget_type": "chart",
54
- "title": "Inventory Status",
55
- "accessible": True
56
- },
57
- {
58
- "widget_id": "wid_order_trend_12m_001",
59
- "widget_type": "chart",
60
- "title": "Order Trend (12-mo)",
61
- "accessible": True
62
- },
63
- {
64
- "widget_id": "wid_supplier_performance_001",
65
- "widget_type": "chart",
66
- "title": "Supplier Performance",
67
- "accessible": True
68
- },
69
- {
70
- "widget_id": "wid_top_5_products_001",
71
- "widget_type": "chart",
72
- "title": "Top 5 Products by Volume",
73
- "accessible": True
74
- }
75
- ]
76
-
77
- # Advanced widgets for managers and above - Supply Chain focused
78
- advanced_widgets = [
79
- {
80
- "widget_id": "wid_goods_receipt_rate_001",
81
- "widget_type": "kpi",
82
- "title": "Goods Receipt Rate (30d)",
83
- "accessible": True
84
- },
85
- {
86
- "widget_id": "wid_stock_turnover_001",
87
- "widget_type": "kpi",
88
- "title": "Stock Turnover Rate",
89
- "accessible": True
90
- },
91
- {
92
- "widget_id": "wid_recent_orders_001",
93
- "widget_type": "table",
94
- "title": "Recent Orders",
95
- "accessible": True
96
- },
97
- {
98
- "widget_id": "wid_recent_grn_001",
99
- "widget_type": "table",
100
- "title": "Recent Goods Receipts",
101
- "accessible": True
102
- },
103
- {
104
- "widget_id": "wid_low_stock_items_001",
105
- "widget_type": "table",
106
- "title": "Low Stock Items",
107
- "accessible": True
108
- },
109
- {
110
- "widget_id": "wid_top_suppliers_001",
111
- "widget_type": "table",
112
- "title": "Top Suppliers (30 Days)",
113
- "accessible": True
114
- },
115
- {
116
- "widget_id": "wid_cancelled_orders_001",
117
- "widget_type": "table",
118
- "title": "Cancelled Orders",
119
- "accessible": True
120
- },
121
- {
122
- "widget_id": "wid_expiring_stock_001",
123
- "widget_type": "table",
124
- "title": "Expiring Stock (Next 30 Days)",
125
- "accessible": True
126
- },
127
- {
128
- "widget_id": "wid_product_reorder_list_001",
129
- "widget_type": "table",
130
- "title": "Products Needing Reorder",
131
- "accessible": True
132
- },
133
- {
134
- "widget_id": "wid_supplier_delivery_performance_001",
135
- "widget_type": "table",
136
- "title": "Supplier Delivery Performance",
137
- "accessible": True
138
- }
139
- ]
140
-
141
- # Return widgets based on role
142
- if user_role.value in ["super_admin", "admin"]:
143
- return base_widgets + advanced_widgets
144
- elif user_role.value in ["manager"]:
145
- return base_widgets + advanced_widgets[:6] # Limited advanced widgets
146
- else:
147
- return base_widgets # Basic widgets only
148
-
149
-
150
- class LoginRequest(BaseModel):
151
- """Login request model."""
152
- email_or_phone: str # Can be email, phone number, or username
153
- password: str
154
-
155
-
156
- class LoginResponse(BaseModel):
157
- """Login response model."""
158
- access_token: str
159
- refresh_token: str
160
- token_type: str = "bearer"
161
- expires_in: int = 1800 # 30 minutes
162
- user: dict
163
- access_menu: dict
164
- warnings: Optional[str] = None
165
-
166
-
167
- class TokenRefreshRequest(BaseModel):
168
- """Token refresh request."""
169
- refresh_token: str
170
-
171
-
172
- @router.post("/login", response_model=LoginResponse)
173
- async def login(
174
- login_data: LoginRequest,
175
- user_service: SystemUserService = Depends(get_system_user_service)
176
- ):
177
- """
178
- Authenticate user and return JWT tokens.
179
-
180
- - **email_or_phone**: User email, phone number, or username
181
- - **password**: User password
182
- """
183
- try:
184
- # Authenticate user
185
- user, message = await user_service.authenticate_user(
186
- login_data.email_or_phone,
187
- login_data.password
188
- )
189
-
190
- if not user:
191
- raise HTTPException(
192
- status_code=status.HTTP_401_UNAUTHORIZED,
193
- detail=message,
194
- headers={"WWW-Authenticate": "Bearer"}
195
- )
196
-
197
- # Create tokens
198
- access_token_expires = timedelta(minutes=30)
199
- access_token = user_service.create_access_token(
200
- data={"sub": user.user_id, "username": user.username, "role": user.role.value, "merchant_id": user.merchant_id},
201
- expires_delta=access_token_expires
202
- )
203
-
204
- refresh_token = user_service.create_refresh_token(
205
- data={"sub": user.user_id, "username": user.username}
206
- )
207
-
208
- # Flatten permissions to dot notation
209
- flattened_permissions = []
210
- for module, actions in user.permissions.items():
211
- for action in actions:
212
- flattened_permissions.append(f"{module}.{action}")
213
-
214
- # Generate accessible widgets based on user role
215
- accessible_widgets = _get_accessible_widgets(user.role)
216
-
217
- # Return user info without sensitive data
218
- user_info = {
219
- "user_id": user.user_id,
220
- "username": user.username,
221
- "email": user.email,
222
- "first_name": user.first_name,
223
- "last_name": user.last_name,
224
- "role": user.role.value,
225
- "permissions": user.permissions,
226
- "status": user.status.value,
227
- "last_login_at": user.last_login_at,
228
- "merchant_info": user.metadata.get("merchant_id") if user.metadata else None
229
- }
230
-
231
- # Access menu structure
232
- access_menu = {
233
- "permissions": flattened_permissions,
234
- "accessible_widgets": accessible_widgets
235
- }
236
-
237
- logger.info(f"User logged in successfully: {user.username}")
238
-
239
- return LoginResponse(
240
- access_token=access_token,
241
- refresh_token=refresh_token,
242
- user=user_info,
243
- access_menu=access_menu,
244
- warnings=None
245
- )
246
-
247
- except HTTPException:
248
- raise
249
- except Exception as e:
250
- logger.error(f"Login error: {e}")
251
- raise HTTPException(
252
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
253
- detail="Authentication failed"
254
- )
255
-
256
-
257
- class OAuth2LoginRequest(BaseModel):
258
- """OAuth2 compatible login request."""
259
- username: str # Can be email or phone
260
- password: str
261
- grant_type: str = "password"
262
-
263
-
264
- @router.post("/login-form")
265
- async def login_form(
266
- form_data: OAuth2LoginRequest,
267
- user_service: SystemUserService = Depends(get_system_user_service)
268
- ):
269
- """
270
- OAuth2 compatible login endpoint for form-based authentication.
271
- """
272
- try:
273
- # Authenticate user
274
- user, message = await user_service.authenticate_user(
275
- form_data.username, # Can be email or phone
276
- form_data.password
277
- )
278
-
279
- if not user:
280
- raise HTTPException(
281
- status_code=status.HTTP_401_UNAUTHORIZED,
282
- detail=message,
283
- headers={"WWW-Authenticate": "Bearer"}
284
- )
285
-
286
- # Create access token
287
- access_token_expires = timedelta(minutes=30)
288
- access_token = user_service.create_access_token(
289
- data={"sub": user.user_id, "username": user.username, "role": user.role.value, "merchant_id": user.merchant_id},
290
- expires_delta=access_token_expires
291
- )
292
-
293
- return {
294
- "access_token": access_token,
295
- "token_type": "bearer"
296
- }
297
-
298
- except HTTPException:
299
- raise
300
- except Exception as e:
301
- logger.error(f"Form login error: {e}")
302
- raise HTTPException(
303
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
304
- detail="Authentication failed"
305
- )
306
-
307
-
308
- @router.post("/refresh")
309
- async def refresh_token(
310
- refresh_data: TokenRefreshRequest,
311
- user_service: SystemUserService = Depends(get_system_user_service)
312
- ):
313
- """
314
- Refresh access token using refresh token.
315
- """
316
- try:
317
- # Verify refresh token
318
- payload = user_service.verify_token(refresh_data.refresh_token, "refresh")
319
- if payload is None:
320
- raise HTTPException(
321
- status_code=status.HTTP_401_UNAUTHORIZED,
322
- detail="Invalid refresh token"
323
- )
324
-
325
- user_id = payload.get("sub")
326
- username = payload.get("username")
327
-
328
- # Get user to verify they still exist and are active
329
- user = await user_service.get_user_by_id(user_id)
330
- if not user or user.status.value != "active":
331
- raise HTTPException(
332
- status_code=status.HTTP_401_UNAUTHORIZED,
333
- detail="User not found or inactive"
334
- )
335
-
336
- # Create new access token
337
- access_token_expires = timedelta(minutes=30)
338
- access_token = user_service.create_access_token(
339
- data={"sub": user_id, "username": username, "role": user.role.value, "merchant_id": user.merchant_id},
340
- expires_delta=access_token_expires
341
- )
342
-
343
- return {
344
- "access_token": access_token,
345
- "token_type": "bearer",
346
- "expires_in": 1800
347
- }
348
-
349
- except HTTPException:
350
- raise
351
- except Exception as e:
352
- logger.error(f"Token refresh error: {e}")
353
- raise HTTPException(
354
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
355
- detail="Token refresh failed"
356
- )
357
-
358
-
359
- @router.get("/me")
360
- async def get_current_user_info(
361
- current_user: SystemUserModel = Depends(get_current_user)
362
- ):
363
- """
364
- Get current user information.
365
- """
366
- return {
367
- "user_id": current_user.user_id,
368
- "username": current_user.username,
369
- "email": current_user.email,
370
- "first_name": current_user.first_name,
371
- "last_name": current_user.last_name,
372
- "role": current_user.role.value,
373
- "permissions": current_user.permissions,
374
- "status": current_user.status.value,
375
- "last_login_at": current_user.last_login_at,
376
- "timezone": current_user.timezone,
377
- "language": current_user.language,
378
- "merchant_info": current_user.metadata.get("merchant_id") if current_user.metadata else None
379
- }
380
-
381
-
382
- @router.post("/logout")
383
- async def logout(
384
- current_user: SystemUserModel = Depends(get_current_user)
385
- ):
386
- """
387
- Logout current user.
388
- Note: In a production environment, you would want to blacklist the token.
389
- """
390
- logger.info(f"User logged out: {current_user.username}")
391
- return {"message": "Successfully logged out"}
392
-
393
-
394
- @router.post("/test-login")
395
- async def test_login():
396
- """
397
- Test endpoint to verify authentication system is working.
398
- Returns sample login credentials.
399
- """
400
- return {
401
- "message": "Authentication system is ready",
402
- "test_credentials": [
403
- {
404
- "type": "Super Admin",
405
- "email": "superadmin@cuatrolabs.com",
406
- "password": "SuperAdmin@123",
407
- "description": "Full system access"
408
- },
409
- {
410
- "type": "Company Admin",
411
- "email": "admin@cuatrolabs.com",
412
- "password": "CompanyAdmin@123",
413
- "description": "Company-wide management"
414
- },
415
- {
416
- "type": "CNF Manager",
417
- "email": "north.manager@cuatrolabs.com",
418
- "password": "CNFManager@123",
419
- "description": "Regional CNF operations"
420
- }
421
- ]
422
- }
423
-
424
-
425
- @router.get("/access-roles")
426
- async def get_access_roles(
427
- user_service: SystemUserService = Depends(get_system_user_service)
428
- ):
429
- """
430
- Get available access roles and their permissions structure.
431
-
432
- Returns the complete role hierarchy with grouped permissions.
433
- """
434
- try:
435
- # Get roles from database
436
- roles = await user_service.get_all_roles()
437
-
438
- return {
439
- "message": "Access roles with grouped permissions structure",
440
- "total_roles": len(roles),
441
- "roles": [
442
- {
443
- "role_id": role.get("role_id"),
444
- "role_name": role.get("role_name"),
445
- "description": role.get("description"),
446
- "permissions": role.get("permissions", {}),
447
- "is_active": role.get("is_active", True)
448
- }
449
- for role in roles
450
- ]
451
- }
452
- except Exception as e:
453
- logger.error(f"Error fetching access roles: {e}")
454
- return {
455
- "message": "Error fetching access roles",
456
- "error": str(e)
457
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/auth/models/auth_log_model.py DELETED
@@ -1,35 +0,0 @@
1
- """
2
- Authentication log model for SCM microservice.
3
- Tracks authentication events for security auditing.
4
- """
5
- from datetime import datetime
6
- from typing import Optional, Dict
7
- from pydantic import BaseModel, Field
8
-
9
-
10
- class AuthLogModel(BaseModel):
11
- """Authentication log data model"""
12
- event_type: str = Field(..., description="Type of authentication event")
13
- merchant_id: Optional[str] = Field(None, description="Merchant identifier")
14
- associate_id: Optional[str] = Field(None, description="Employee identifier")
15
- identifier: str = Field(..., description="Email or mobile used")
16
- ip_address: Optional[str] = Field(None, description="Client IP address")
17
- user_agent: Optional[str] = Field(None, description="Client user agent")
18
- timestamp: datetime = Field(default_factory=datetime.utcnow)
19
- result: str = Field(..., description="Event result (success/failure)")
20
- failure_reason: Optional[str] = Field(None, description="Reason for failure")
21
- metadata: Dict = Field(default_factory=dict, description="Additional context")
22
-
23
- class Config:
24
- json_schema_extra = {
25
- "example": {
26
- "event_type": "login_attempt",
27
- "merchant_id": "SALON_US_NYC_001",
28
- "associate_id": "SALON_US_NYC_001_e001",
29
- "identifier": "jane@glamoursalon.com",
30
- "ip_address": "192.168.1.100",
31
- "user_agent": "Mozilla/5.0...",
32
- "result": "success",
33
- "metadata": {}
34
- }
35
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/auth/models/role_model.py DELETED
@@ -1,47 +0,0 @@
1
- """
2
- Role model for SCM microservice.
3
- Represents a role with permissions within a merchant.
4
- """
5
- from datetime import datetime
6
- from typing import Dict, List, Optional
7
- from pydantic import BaseModel, Field
8
-
9
-
10
- class RoleModel(BaseModel):
11
- """Role data model"""
12
- role_id: str = Field(..., description="Role identifier")
13
- merchant_id: str = Field(..., description="Merchant identifier")
14
- name: str = Field(..., description="Role name")
15
- description: Optional[str] = Field(None, description="Role description")
16
- permissions: Dict[str, List[str]] = Field(
17
- default_factory=dict,
18
- description="Module-based permissions mapping"
19
- )
20
- scope: Dict = Field(
21
- default_factory=lambda: {"global": True, "branches": []},
22
- description="Role scope configuration"
23
- )
24
- created_at: datetime = Field(default_factory=datetime.utcnow)
25
- updated_at: Optional[datetime] = None
26
- created_by: str = Field(default="system")
27
- archived: bool = Field(default=False)
28
-
29
- class Config:
30
- json_schema_extra = {
31
- "example": {
32
- "role_id": "admin",
33
- "merchant_id": "SALON_US_NYC_001",
34
- "name": "Administrator",
35
- "description": "Full access to all system features",
36
- "permissions": {
37
- "inventory": ["create", "read", "update", "delete"],
38
- "orders": ["create", "read", "update", "delete"],
39
- "reports": ["read"]
40
- },
41
- "scope": {
42
- "global": True,
43
- "branches": []
44
- },
45
- "archived": False
46
- }
47
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/catalogues/controllers/router.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Catalogues API router - FastAPI endpoints for product catalogues.
3
+ """
4
+ from typing import Optional, List
5
+ from fastapi import APIRouter, HTTPException, Query, status
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ router = APIRouter(
11
+ prefix="/catalogues",
12
+ tags=["catalogues"],
13
+ responses={404: {"description": "Not found"}},
14
+ )
15
+
16
+
17
+ @router.get(
18
+ "",
19
+ summary="List catalogues",
20
+ description="Get a list of all product catalogues"
21
+ )
22
+ async def list_catalogues(
23
+ skip: int = Query(0, ge=0, description="Number of records to skip"),
24
+ limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return")
25
+ ):
26
+ """
27
+ List all catalogues with pagination.
28
+ """
29
+ logger.info(f"Listing catalogues with skip={skip}, limit={limit}")
30
+
31
+ # TODO: Implement catalogue listing from database
32
+ return {
33
+ "message": "Catalogues endpoint - coming soon",
34
+ "catalogues": [],
35
+ "total": 0,
36
+ "skip": skip,
37
+ "limit": limit
38
+ }
39
+
40
+
41
+ @router.get(
42
+ "/{catalogue_id}",
43
+ summary="Get catalogue by ID",
44
+ description="Get a specific catalogue by its ID"
45
+ )
46
+ async def get_catalogue(catalogue_id: str):
47
+ """
48
+ Get a catalogue by ID.
49
+ """
50
+ logger.info(f"Getting catalogue {catalogue_id}")
51
+
52
+ # TODO: Implement catalogue retrieval from database
53
+ raise HTTPException(
54
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
55
+ detail="Catalogue retrieval not yet implemented"
56
+ )
57
+
58
+
59
+ @router.post(
60
+ "",
61
+ status_code=status.HTTP_201_CREATED,
62
+ summary="Create a new catalogue",
63
+ description="Create a new product catalogue"
64
+ )
65
+ async def create_catalogue():
66
+ """
67
+ Create a new catalogue.
68
+ """
69
+ logger.info("Creating new catalogue")
70
+
71
+ # TODO: Implement catalogue creation
72
+ raise HTTPException(
73
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
74
+ detail="Catalogue creation not yet implemented"
75
+ )
76
+
77
+
78
+ @router.put(
79
+ "/{catalogue_id}",
80
+ summary="Update catalogue",
81
+ description="Update an existing catalogue"
82
+ )
83
+ async def update_catalogue(catalogue_id: str):
84
+ """
85
+ Update a catalogue.
86
+ """
87
+ logger.info(f"Updating catalogue {catalogue_id}")
88
+
89
+ # TODO: Implement catalogue update
90
+ raise HTTPException(
91
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
92
+ detail="Catalogue update not yet implemented"
93
+ )
94
+
95
+
96
+ @router.delete(
97
+ "/{catalogue_id}",
98
+ summary="Delete catalogue",
99
+ description="Delete a catalogue"
100
+ )
101
+ async def delete_catalogue(catalogue_id: str):
102
+ """
103
+ Delete a catalogue.
104
+ """
105
+ logger.info(f"Deleting catalogue {catalogue_id}")
106
+
107
+ # TODO: Implement catalogue deletion
108
+ raise HTTPException(
109
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
110
+ detail="Catalogue deletion not yet implemented"
111
+ )
app/constants/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Constants module for POS microservice."""
app/constants/collections.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MongoDB collection names for POS microservice.
3
+ """
4
+
5
+ # System Users collection (shared with Auth/SCM services)
6
+ AUTH_SYSTEM_USERS_COLLECTION = "scm_system_users"
7
+ AUTH_ACCESS_ROLES_COLLECTION = "scm_access_roles"
8
+ AUTH_AUTH_LOGS_COLLECTION = "scm_auth_logs"
9
+
10
+ # POS-specific collections
11
+ POS_SALES_COLLECTION = "pos_sales"
12
+ POS_INVENTORY_COLLECTION = "pos_inventory"
13
+ POS_CUSTOMERS_COLLECTION = "pos_customers"
14
+ POS_PRODUCTS_COLLECTION = "pos_products"
15
+ POS_TRANSACTIONS_COLLECTION = "pos_transactions"
16
+ POS_PAYMENTS_COLLECTION = "pos_payments"
17
+ POS_RECEIPTS_COLLECTION = "pos_receipts"
18
+
19
+ # SCM collection names (for cross-service access)
20
+ SCM_ACCESS_ROLES_COLLECTION = "scm_access_roles"
21
+ SCM_EMPLOYEES_COLLECTION = "scm_employees"
app/constants/employee_types.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Employee/Staff-related constants and enumerations.
3
+ """
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class Designation(str, Enum):
9
+ """Staff designation/role types."""
10
+ RSM = "RSM" # Regional Sales Manager
11
+ ASM = "ASM" # Area Sales Manager
12
+ BDE = "BDE" # Business Development Executive
13
+ HEAD_TRAINER = "Head_Trainer"
14
+ TRAINER = "Trainer"
15
+ HR = "HR"
16
+ FINANCE = "Finance"
17
+ ADMIN = "Admin"
18
+ FIELD_SALES = "Field_Sales"
19
+ CASHIER = "Cashier" # POS specific
20
+ STORE_MANAGER = "Store_Manager" # POS specific
21
+ SALES_ASSOCIATE = "Sales_Associate" # POS specific
22
+
23
+
24
+ class stafftatus(str, Enum):
25
+ """Staff status types."""
26
+ ONBOARDING = "onboarding"
27
+ ACTIVE = "active"
28
+ INACTIVE = "inactive"
29
+ SUSPENDED = "suspended"
30
+ TERMINATED = "terminated"
31
+
32
+
33
+ class LocationPrecision(str, Enum):
34
+ """Location tracking precision levels."""
35
+ HIGH = "HIGH"
36
+ MEDIUM = "MEDIUM"
37
+ LOW = "LOW"
38
+
39
+
40
+ class IDDocumentType(str, Enum):
41
+ """Identity document types."""
42
+ PAN = "PAN"
43
+ AADHAAR = "AADHAAR"
44
+ DRIVING_LICENSE = "DRIVING_LICENSE"
45
+ PASSPORT = "PASSPORT"
46
+ OTHER = "OTHER"
47
+
48
+
49
+ class DevicePlatform(str, Enum):
50
+ """Mobile device platforms."""
51
+ ANDROID = "Android"
52
+ IOS = "iOS"
53
+ WEB = "Web"
54
+
55
+
56
+ # Hierarchy rules for manager validation
57
+ MANAGER_ALLOWED_DESIGNATIONS = {
58
+ Designation.ASM: [Designation.RSM],
59
+ Designation.BDE: [Designation.ASM, Designation.RSM],
60
+ Designation.TRAINER: [Designation.ASM, Designation.RSM, Designation.HEAD_TRAINER],
61
+ Designation.FIELD_SALES: [Designation.ASM, Designation.RSM, Designation.BDE],
62
+ Designation.CASHIER: [Designation.STORE_MANAGER],
63
+ Designation.SALES_ASSOCIATE: [Designation.STORE_MANAGER],
64
+ }
65
+
66
+ # Designations that require a manager
67
+ MANAGER_REQUIRED_DESIGNATIONS = [
68
+ Designation.ASM,
69
+ Designation.BDE,
70
+ Designation.TRAINER,
71
+ Designation.FIELD_SALES,
72
+ Designation.CASHIER,
73
+ Designation.SALES_ASSOCIATE,
74
+ ]
75
+
76
+ # Designations that require 2FA by default
77
+ TWO_FA_REQUIRED_DESIGNATIONS = [
78
+ Designation.ADMIN,
79
+ Designation.FINANCE,
80
+ Designation.HR,
81
+ Designation.STORE_MANAGER,
82
+ ]
83
+
84
+ # Default location retention period (days)
85
+ DEFAULT_LOCATION_RETENTION_DAYS = 90
86
+ MAX_LOCATION_RETENTION_DAYS = 180
87
+
88
+ # Age constraints
89
+ MIN_AGE_YEARS = 18
90
+ MAX_FUTURE_DOJ_DAYS = 30
91
+
92
+ # Validation regex patterns
93
+ EMPLOYEE_CODE_PATTERN = r"^[A-Z0-9\-_]{3,20}$"
94
+ PHONE_E164_PATTERN = r"^\+?[1-9]\d{7,14}$"
95
+ PAN_PATTERN = r"^[A-Z]{5}[0-9]{4}[A-Z]$"
96
+ AADHAAR_PATTERN = r"^\d{12}$"
97
+
98
+ # Collection name
99
+ SCM_staff_COLLECTION = "pos_staff"
app/db_init.py CHANGED
@@ -40,7 +40,7 @@ async def initialize_database():
40
 
41
  # Add other collection initializations here as needed
42
  # await initialize_merchants_database(db)
43
- # await initialize_employees_database(db)
44
  # await initialize_orders_database(db)
45
 
46
  logger.info("Database initialization completed successfully")
 
40
 
41
  # Add other collection initializations here as needed
42
  # await initialize_merchants_database(db)
43
+ # await initialize_staff_database(db)
44
  # await initialize_orders_database(db)
45
 
46
  logger.info("Database initialization completed successfully")
app/dependencies/auth.py CHANGED
@@ -1,33 +1,52 @@
1
  """
2
  Authentication dependencies for FastAPI.
 
 
3
  """
4
- from typing import Optional
5
  from datetime import datetime
6
  from fastapi import Depends, HTTPException, status
7
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
8
- from app.system_users.models.model import SystemUserModel
9
- from app.system_users.services.service import SystemUserService
10
- from app.nosql import get_database
11
 
12
  security = HTTPBearer()
13
 
14
 
15
- def get_system_user_service() -> SystemUserService:
 
 
 
 
 
 
 
 
 
16
  """
17
- Dependency to get SystemUserService instance.
18
 
 
 
 
19
  Returns:
20
- SystemUserService: Service instance with database connection
21
  """
22
- # get_database() returns AsyncIOMotorDatabase directly, no await needed
23
- db = get_database()
24
- return SystemUserService(db)
 
 
 
 
 
 
25
 
26
 
27
  async def get_current_user(
28
- credentials: HTTPAuthorizationCredentials = Depends(security),
29
- user_service: SystemUserService = Depends(get_system_user_service)
30
- ) -> SystemUserModel:
31
  """Get current authenticated user from JWT token."""
32
 
33
  credentials_exception = HTTPException(
@@ -38,44 +57,42 @@ async def get_current_user(
38
 
39
  try:
40
  # Verify token
41
- payload = user_service.verify_token(credentials.credentials, "access")
42
  if payload is None:
43
  raise credentials_exception
44
 
45
  user_id: str = payload.get("sub")
46
- if user_id is None:
 
 
 
 
47
  raise credentials_exception
 
 
 
 
 
 
 
 
48
 
49
  except Exception:
50
  raise credentials_exception
51
-
52
- # Get user from database
53
- user = await user_service.get_user_by_id(user_id)
54
- if user is None:
55
- raise credentials_exception
56
-
57
- # Check if user is active
58
- if user.status.value != "active":
59
- raise HTTPException(
60
- status_code=status.HTTP_403_FORBIDDEN,
61
- detail="User account is not active"
62
- )
63
-
64
- return user
65
 
66
 
67
  async def get_current_active_user(
68
- current_user: SystemUserModel = Depends(get_current_user)
69
- ) -> SystemUserModel:
70
  """Get current active user (alias for get_current_user for clarity)."""
71
  return current_user
72
 
73
 
74
  async def require_admin_role(
75
- current_user: SystemUserModel = Depends(get_current_user)
76
- ) -> SystemUserModel:
77
  """Require admin or super_admin role."""
78
- if not current_user.is_admin():
79
  raise HTTPException(
80
  status_code=status.HTTP_403_FORBIDDEN,
81
  detail="Admin privileges required"
@@ -84,10 +101,10 @@ async def require_admin_role(
84
 
85
 
86
  async def require_super_admin_role(
87
- current_user: SystemUserModel = Depends(get_current_user)
88
- ) -> SystemUserModel:
89
  """Require super_admin role."""
90
- if not current_user.is_super_admin():
91
  raise HTTPException(
92
  status_code=status.HTTP_403_FORBIDDEN,
93
  detail="Super admin privileges required"
@@ -98,9 +115,9 @@ async def require_super_admin_role(
98
  def require_permission(permission: str):
99
  """Dependency factory to require specific permission."""
100
  async def permission_checker(
101
- current_user: SystemUserModel = Depends(get_current_user)
102
- ) -> SystemUserModel:
103
- if permission not in current_user.permissions and not current_user.is_admin():
104
  raise HTTPException(
105
  status_code=status.HTTP_403_FORBIDDEN,
106
  detail=f"Permission '{permission}' required"
@@ -111,9 +128,8 @@ def require_permission(permission: str):
111
 
112
 
113
  async def get_optional_user(
114
- credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
115
- user_service: SystemUserService = Depends(get_system_user_service)
116
- ) -> Optional[SystemUserModel]:
117
  """Get current user if token is provided, otherwise return None."""
118
 
119
  if credentials is None:
@@ -121,20 +137,25 @@ async def get_optional_user(
121
 
122
  try:
123
  # Verify token
124
- payload = user_service.verify_token(credentials.credentials, "access")
125
  if payload is None:
126
  return None
127
 
128
  user_id: str = payload.get("sub")
129
- if user_id is None:
130
- return None
131
-
132
- # Get user from database
133
- user = await user_service.get_user_by_id(user_id)
134
- if user is None or user.status.value != "active":
135
  return None
136
 
137
- return user
 
 
 
 
 
 
138
 
139
  except Exception:
140
  return None
 
1
  """
2
  Authentication dependencies for FastAPI.
3
+ Simplified token validation without system_users dependency.
4
+ POS microservice validates tokens issued by auth-ms but doesn't manage users.
5
  """
6
+ from typing import Optional, Dict, Any
7
  from datetime import datetime
8
  from fastapi import Depends, HTTPException, status
9
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
10
+ from jose import JWTError, jwt
11
+ from pydantic import BaseModel
12
+ from app.core.config import settings
13
 
14
  security = HTTPBearer()
15
 
16
 
17
+ class TokenUser(BaseModel):
18
+ """Lightweight user model from JWT token payload."""
19
+ user_id: str
20
+ username: str
21
+ role: str
22
+ merchant_id: Optional[str] = None
23
+ permissions: Dict[str, Any] = {}
24
+
25
+
26
+ def verify_token(token: str) -> Optional[Dict[str, Any]]:
27
  """
28
+ Verify JWT token from auth microservice.
29
 
30
+ Args:
31
+ token: JWT token string
32
+
33
  Returns:
34
+ Token payload dict if valid, None otherwise
35
  """
36
+ try:
37
+ payload = jwt.decode(
38
+ token,
39
+ settings.SECRET_KEY,
40
+ algorithms=[settings.ALGORITHM]
41
+ )
42
+ return payload
43
+ except JWTError:
44
+ return None
45
 
46
 
47
  async def get_current_user(
48
+ credentials: HTTPAuthorizationCredentials = Depends(security)
49
+ ) -> TokenUser:
 
50
  """Get current authenticated user from JWT token."""
51
 
52
  credentials_exception = HTTPException(
 
57
 
58
  try:
59
  # Verify token
60
+ payload = verify_token(credentials.credentials)
61
  if payload is None:
62
  raise credentials_exception
63
 
64
  user_id: str = payload.get("sub")
65
+ username: str = payload.get("username")
66
+ role: str = payload.get("role", "user")
67
+ merchant_id: str = payload.get("merchant_id")
68
+
69
+ if user_id is None or username is None:
70
  raise credentials_exception
71
+
72
+ return TokenUser(
73
+ user_id=user_id,
74
+ username=username,
75
+ role=role,
76
+ merchant_id=merchant_id,
77
+ permissions=payload.get("permissions", {})
78
+ )
79
 
80
  except Exception:
81
  raise credentials_exception
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
 
84
  async def get_current_active_user(
85
+ current_user: TokenUser = Depends(get_current_user)
86
+ ) -> TokenUser:
87
  """Get current active user (alias for get_current_user for clarity)."""
88
  return current_user
89
 
90
 
91
  async def require_admin_role(
92
+ current_user: TokenUser = Depends(get_current_user)
93
+ ) -> TokenUser:
94
  """Require admin or super_admin role."""
95
+ if current_user.role not in ["admin", "super_admin"]:
96
  raise HTTPException(
97
  status_code=status.HTTP_403_FORBIDDEN,
98
  detail="Admin privileges required"
 
101
 
102
 
103
  async def require_super_admin_role(
104
+ current_user: TokenUser = Depends(get_current_user)
105
+ ) -> TokenUser:
106
  """Require super_admin role."""
107
+ if current_user.role != "super_admin":
108
  raise HTTPException(
109
  status_code=status.HTTP_403_FORBIDDEN,
110
  detail="Super admin privileges required"
 
115
  def require_permission(permission: str):
116
  """Dependency factory to require specific permission."""
117
  async def permission_checker(
118
+ current_user: TokenUser = Depends(get_current_user)
119
+ ) -> TokenUser:
120
+ if permission not in current_user.permissions and current_user.role not in ["admin", "super_admin"]:
121
  raise HTTPException(
122
  status_code=status.HTTP_403_FORBIDDEN,
123
  detail=f"Permission '{permission}' required"
 
128
 
129
 
130
  async def get_optional_user(
131
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False))
132
+ ) -> Optional[TokenUser]:
 
133
  """Get current user if token is provided, otherwise return None."""
134
 
135
  if credentials is None:
 
137
 
138
  try:
139
  # Verify token
140
+ payload = verify_token(credentials.credentials)
141
  if payload is None:
142
  return None
143
 
144
  user_id: str = payload.get("sub")
145
+ username: str = payload.get("username")
146
+ role: str = payload.get("role", "user")
147
+ merchant_id: str = payload.get("merchant_id")
148
+
149
+ if user_id is None or username is None:
 
150
  return None
151
 
152
+ return TokenUser(
153
+ user_id=user_id,
154
+ username=username,
155
+ role=role,
156
+ merchant_id=merchant_id,
157
+ permissions=payload.get("permissions", {})
158
+ )
159
 
160
  except Exception:
161
  return None
app/employees/__init__.py DELETED
File without changes
app/employees/controllers/__init__.py DELETED
File without changes
app/employees/models/__init__.py DELETED
File without changes
app/employees/schemas/__init__.py DELETED
File without changes
app/employees/services/__init__.py DELETED
File without changes
app/main.py CHANGED
@@ -9,10 +9,10 @@ import logging # TODO: Uncomment when package is available
9
  from app.core.config import settings
10
 
11
  from app.nosql import connect_to_mongo, close_mongo_connection
12
- from app.auth.controllers.router import router as auth_router
 
13
 
14
  # # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
15
- logger = logging.getLogger(__name__) # TODO: Uncomment when insightfy_utils is available
16
  logger = logging.getLogger(__name__)
17
  logging.basicConfig(level=logging.INFO)
18
 
@@ -63,14 +63,16 @@ async def health_check():
63
  }
64
 
65
 
66
- # Include routers
67
- app.include_router(auth_router)
68
 
69
  # TODO: Add other POS-specific routers as they are implemented:
70
- # app.include_router(sales_router)
71
- # app.include_router(inventory_router)
72
- # app.include_router(customer_router)
73
- # app.include_router(product_router)
 
 
74
  # app.include_router(payment_router)
75
  # app.include_router(report_router)
76
 
 
9
  from app.core.config import settings
10
 
11
  from app.nosql import connect_to_mongo, close_mongo_connection
12
+ from app.staff.controllers.router import router as staff_router
13
+ from app.catalogues.controllers.router import router as catalogues_router
14
 
15
  # # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
 
16
  logger = logging.getLogger(__name__)
17
  logging.basicConfig(level=logging.INFO)
18
 
 
63
  }
64
 
65
 
66
+ app.include_router(staff_router, prefix="/api/v1")
67
+ app.include_router(catalogues_router, prefix="/api/v1")
68
 
69
  # TODO: Add other POS-specific routers as they are implemented:
70
+ # app.include_router(sales_router, prefix="/api/v1")
71
+ # app.include_router(inventory_router, prefix="/api/v1")
72
+ # app.include_router(customer_router, prefix="/api/v1")
73
+ # app.include_router(product_router, prefix="/api/v1")
74
+ # app.include_router(payment_router, prefix="/api/v1")
75
+ # app.include_router(report_router, prefix="/api/v1"r)
76
  # app.include_router(payment_router)
77
  # app.include_router(report_router)
78
 
app/{auth β†’ staff}/__init__.py RENAMED
File without changes
app/{auth β†’ staff}/controllers/__init__.py RENAMED
File without changes
app/{employees β†’ staff}/controllers/router.py RENAMED
@@ -6,16 +6,16 @@ from fastapi import APIRouter, HTTPException, Query, Header, status
6
  # from insightfy_utils.logging import get_logger # TODO: Uncomment when package is available
7
  import logging
8
 
9
- from app.constants.employee_types import Designation, EmployeeStatus
10
- from app.employees.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
11
- from app.employees.services.service import EmployeeService
12
 
13
  # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
14
  logger = logging.getLogger(__name__)
15
 
16
  router = APIRouter(
17
- prefix="/employees",
18
- tags=["employees"],
19
  responses={404: {"description": "Not found"}},
20
  )
21
 
@@ -28,7 +28,7 @@ router = APIRouter(
28
  description="""
29
  Create a new employee with comprehensive validation:
30
  - Validates employee code uniqueness
31
- - Ensures email and phone uniqueness among active employees
32
  - Enforces manager hierarchy rules
33
  - Validates age requirements (minimum 18 years)
34
  - Validates DOB/DOJ consistency
@@ -41,9 +41,9 @@ async def create_employee(payload: EmployeeCreate) -> EmployeeResponse:
41
  Create a new employee.
42
 
43
  **Business Rules:**
44
- - Employee code must be unique across all employees
45
- - Email must be unique among active employees
46
- - Phone must be unique among active employees
47
  - Employee must be at least 18 years old
48
  - DOJ cannot be more than 30 days in the future
49
  - ASM must have an RSM manager
@@ -63,7 +63,7 @@ async def create_employee(payload: EmployeeCreate) -> EmployeeResponse:
63
  - Background tracking requires location_tracking_consent
64
  - Requires mobile app access (has_mobile_app=True)
65
  """
66
- return await EmployeeService.create_employee(payload)
67
 
68
 
69
  @router.get(
@@ -85,7 +85,7 @@ async def get_employee(user_id: str) -> EmployeeResponse:
85
  Raises:
86
  404: Employee not found
87
  """
88
- return await EmployeeService.get_employee(user_id)
89
 
90
 
91
  @router.get(
@@ -107,7 +107,7 @@ async def get_employee_by_code(employee_code: str) -> EmployeeResponse:
107
  Raises:
108
  404: Employee not found
109
  """
110
- return await EmployeeService.get_employee_by_code(employee_code)
111
 
112
 
113
  @router.put(
@@ -141,16 +141,16 @@ async def update_employee(
141
  - Phone uniqueness (if changing phone)
142
  - Manager validation (if changing manager)
143
  """
144
- return await EmployeeService.update_employee(user_id, payload, x_user_id)
145
 
146
 
147
  @router.get(
148
  "",
149
  response_model=List[EmployeeResponse],
150
- summary="List employees",
151
- description="List employees with optional filters and pagination"
152
  )
153
- async def list_employees(
154
  designation: Optional[Designation] = Query(
155
  None,
156
  description="Filter by designation/role"
@@ -159,7 +159,7 @@ async def list_employees(
159
  None,
160
  description="Filter by manager's user_id (direct reports)"
161
  ),
162
- status_filter: Optional[EmployeeStatus] = Query(
163
  None,
164
  alias="status",
165
  description="Filter by employee status"
@@ -181,7 +181,7 @@ async def list_employees(
181
  )
182
  ) -> List[EmployeeResponse]:
183
  """
184
- List employees with filters.
185
 
186
  **Query Parameters:**
187
  - `designation`: Filter by role (RSM, ASM, BDE, Trainer, etc.)
@@ -192,9 +192,9 @@ async def list_employees(
192
  - `limit`: Page size (default: 100, max: 500)
193
 
194
  Returns:
195
- List of employees matching the filters
196
  """
197
- return await EmployeeService.list_employees(
198
  designation=designation,
199
  manager_id=manager_id,
200
  status_filter=status_filter,
@@ -216,7 +216,7 @@ async def delete_employee(
216
  """
217
  Delete an employee (soft delete).
218
 
219
- Sets employee status to 'terminated'. Cannot delete employees with active direct reports.
220
 
221
  Args:
222
  user_id: Employee user_id to delete
@@ -229,7 +229,7 @@ async def delete_employee(
229
  404: Employee not found
230
  400: Employee has active direct reports
231
  """
232
- return await EmployeeService.delete_employee(user_id, x_user_id)
233
 
234
 
235
  @router.get(
@@ -240,7 +240,7 @@ async def delete_employee(
240
  )
241
  async def get_employee_reports(
242
  user_id: str,
243
- status_filter: Optional[EmployeeStatus] = Query(
244
  None,
245
  alias="status",
246
  description="Filter by report status"
@@ -260,12 +260,12 @@ async def get_employee_reports(
260
  limit: Page size
261
 
262
  Returns:
263
- List of direct report employees
264
  """
265
  # First verify employee exists
266
- await EmployeeService.get_employee(user_id)
267
 
268
- return await EmployeeService.list_employees(
269
  manager_id=user_id,
270
  status_filter=status_filter,
271
  skip=skip,
@@ -288,7 +288,7 @@ async def get_employee_hierarchy(user_id: str):
288
  user_id: Employee user_id
289
 
290
  Returns:
291
- List of employees from top manager to current employee
292
  """
293
  hierarchy = []
294
  current_id = user_id
@@ -297,7 +297,7 @@ async def get_employee_hierarchy(user_id: str):
297
  # Traverse up the hierarchy
298
  while current_id and current_id not in visited:
299
  visited.add(current_id)
300
- employee_data = await EmployeeService.get_employee(current_id)
301
  hierarchy.insert(0, employee_data) # Insert at beginning to maintain order
302
  current_id = employee_data.manager_id
303
 
@@ -316,7 +316,7 @@ async def get_employee_hierarchy(user_id: str):
316
  )
317
  async def update_employee_status(
318
  user_id: str,
319
- new_status: EmployeeStatus,
320
  x_user_id: str = Header(..., description="User ID making the update")
321
  ) -> EmployeeResponse:
322
  """
@@ -339,7 +339,7 @@ async def update_employee_status(
339
  - active/inactive/suspended β†’ terminated (termination)
340
  """
341
  update_payload = EmployeeUpdate(status=new_status)
342
- return await EmployeeService.update_employee(user_id, update_payload, x_user_id)
343
 
344
 
345
  @router.patch(
@@ -378,7 +378,7 @@ async def update_location_consent(
378
  - Consent timestamp is automatically recorded
379
  """
380
  from datetime import datetime
381
- from app.employees.schemas.schema import LocationSettingsSchema
382
 
383
  location_settings = LocationSettingsSchema(
384
  location_tracking_consent=location_tracking_consent,
@@ -389,4 +389,4 @@ async def update_location_consent(
389
  )
390
 
391
  update_payload = EmployeeUpdate(location_settings=location_settings)
392
- return await EmployeeService.update_employee(user_id, update_payload, x_user_id)
 
6
  # from insightfy_utils.logging import get_logger # TODO: Uncomment when package is available
7
  import logging
8
 
9
+ from app.constants.employee_types import Designation, stafftatus
10
+ from app.staff.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
11
+ from app.staff.services.service import staffervice
12
 
13
  # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
14
  logger = logging.getLogger(__name__)
15
 
16
  router = APIRouter(
17
+ prefix="/staff",
18
+ tags=["staff"],
19
  responses={404: {"description": "Not found"}},
20
  )
21
 
 
28
  description="""
29
  Create a new employee with comprehensive validation:
30
  - Validates employee code uniqueness
31
+ - Ensures email and phone uniqueness among active staff
32
  - Enforces manager hierarchy rules
33
  - Validates age requirements (minimum 18 years)
34
  - Validates DOB/DOJ consistency
 
41
  Create a new employee.
42
 
43
  **Business Rules:**
44
+ - Employee code must be unique across all staff
45
+ - Email must be unique among active staff
46
+ - Phone must be unique among active staff
47
  - Employee must be at least 18 years old
48
  - DOJ cannot be more than 30 days in the future
49
  - ASM must have an RSM manager
 
63
  - Background tracking requires location_tracking_consent
64
  - Requires mobile app access (has_mobile_app=True)
65
  """
66
+ return await staffervice.create_employee(payload)
67
 
68
 
69
  @router.get(
 
85
  Raises:
86
  404: Employee not found
87
  """
88
+ return await staffervice.get_employee(user_id)
89
 
90
 
91
  @router.get(
 
107
  Raises:
108
  404: Employee not found
109
  """
110
+ return await staffervice.get_employee_by_code(employee_code)
111
 
112
 
113
  @router.put(
 
141
  - Phone uniqueness (if changing phone)
142
  - Manager validation (if changing manager)
143
  """
144
+ return await staffervice.update_employee(user_id, payload, x_user_id)
145
 
146
 
147
  @router.get(
148
  "",
149
  response_model=List[EmployeeResponse],
150
+ summary="List staff",
151
+ description="List staff with optional filters and pagination"
152
  )
153
+ async def list_staff(
154
  designation: Optional[Designation] = Query(
155
  None,
156
  description="Filter by designation/role"
 
159
  None,
160
  description="Filter by manager's user_id (direct reports)"
161
  ),
162
+ status_filter: Optional[stafftatus] = Query(
163
  None,
164
  alias="status",
165
  description="Filter by employee status"
 
181
  )
182
  ) -> List[EmployeeResponse]:
183
  """
184
+ List staff with filters.
185
 
186
  **Query Parameters:**
187
  - `designation`: Filter by role (RSM, ASM, BDE, Trainer, etc.)
 
192
  - `limit`: Page size (default: 100, max: 500)
193
 
194
  Returns:
195
+ List of staff matching the filters
196
  """
197
+ return await staffervice.list_staff(
198
  designation=designation,
199
  manager_id=manager_id,
200
  status_filter=status_filter,
 
216
  """
217
  Delete an employee (soft delete).
218
 
219
+ Sets employee status to 'terminated'. Cannot delete staff with active direct reports.
220
 
221
  Args:
222
  user_id: Employee user_id to delete
 
229
  404: Employee not found
230
  400: Employee has active direct reports
231
  """
232
+ return await staffervice.delete_employee(user_id, x_user_id)
233
 
234
 
235
  @router.get(
 
240
  )
241
  async def get_employee_reports(
242
  user_id: str,
243
+ status_filter: Optional[stafftatus] = Query(
244
  None,
245
  alias="status",
246
  description="Filter by report status"
 
260
  limit: Page size
261
 
262
  Returns:
263
+ List of direct report staff
264
  """
265
  # First verify employee exists
266
+ await staffervice.get_employee(user_id)
267
 
268
+ return await staffervice.list_staff(
269
  manager_id=user_id,
270
  status_filter=status_filter,
271
  skip=skip,
 
288
  user_id: Employee user_id
289
 
290
  Returns:
291
+ List of staff from top manager to current employee
292
  """
293
  hierarchy = []
294
  current_id = user_id
 
297
  # Traverse up the hierarchy
298
  while current_id and current_id not in visited:
299
  visited.add(current_id)
300
+ employee_data = await staffervice.get_employee(current_id)
301
  hierarchy.insert(0, employee_data) # Insert at beginning to maintain order
302
  current_id = employee_data.manager_id
303
 
 
316
  )
317
  async def update_employee_status(
318
  user_id: str,
319
+ new_status: stafftatus,
320
  x_user_id: str = Header(..., description="User ID making the update")
321
  ) -> EmployeeResponse:
322
  """
 
339
  - active/inactive/suspended β†’ terminated (termination)
340
  """
341
  update_payload = EmployeeUpdate(status=new_status)
342
+ return await staffervice.update_employee(user_id, update_payload, x_user_id)
343
 
344
 
345
  @router.patch(
 
378
  - Consent timestamp is automatically recorded
379
  """
380
  from datetime import datetime
381
+ from app.staff.schemas.schema import LocationSettingsSchema
382
 
383
  location_settings = LocationSettingsSchema(
384
  location_tracking_consent=location_tracking_consent,
 
389
  )
390
 
391
  update_payload = EmployeeUpdate(location_settings=location_settings)
392
+ return await staffervice.update_employee(user_id, update_payload, x_user_id)
app/{auth β†’ staff}/models/__init__.py RENAMED
File without changes
app/{employees β†’ staff}/models/model.py RENAMED
@@ -8,7 +8,7 @@ from pydantic import BaseModel, Field
8
 
9
  from app.constants.employee_types import (
10
  Designation,
11
- EmployeeStatus,
12
  LocationPrecision,
13
  DevicePlatform,
14
  DEFAULT_LOCATION_RETENTION_DAYS
@@ -128,8 +128,8 @@ class EmployeeModel(BaseModel):
128
  emergency_contact: EmergencyContactModel = Field(..., description="Emergency contact information")
129
 
130
  # Status
131
- status: EmployeeStatus = Field(
132
- default=EmployeeStatus.ONBOARDING,
133
  description="Employee status"
134
  )
135
 
 
8
 
9
  from app.constants.employee_types import (
10
  Designation,
11
+ stafftatus,
12
  LocationPrecision,
13
  DevicePlatform,
14
  DEFAULT_LOCATION_RETENTION_DAYS
 
128
  emergency_contact: EmergencyContactModel = Field(..., description="Emergency contact information")
129
 
130
  # Status
131
+ status: stafftatus = Field(
132
+ default=stafftatus.ONBOARDING,
133
  description="Employee status"
134
  )
135
 
app/{auth β†’ staff}/schemas/__init__.py RENAMED
File without changes
app/{employees β†’ staff}/schemas/schema.py RENAMED
@@ -8,7 +8,7 @@ import re
8
 
9
  from app.constants.employee_types import (
10
  Designation,
11
- EmployeeStatus,
12
  LocationPrecision,
13
  IDDocumentType,
14
  DevicePlatform,
@@ -250,8 +250,8 @@ class EmployeeCreate(BaseModel):
250
  emergency_contact: EmergencyContactSchema = Field(..., description="Emergency contact information")
251
 
252
  # Status
253
- status: EmployeeStatus = Field(
254
- default=EmployeeStatus.ONBOARDING,
255
  description="Employee status"
256
  )
257
 
@@ -414,7 +414,7 @@ class EmployeeUpdate(BaseModel):
414
  photo_url: Optional[str] = None
415
  id_docs: Optional[List[IDDocumentSchema]] = None
416
  emergency_contact: Optional[EmergencyContactSchema] = None
417
- status: Optional[EmployeeStatus] = None
418
  app_access: Optional[AppAccessSchema] = None
419
  location_settings: Optional[LocationSettingsSchema] = None
420
  metadata: Optional[Dict[str, Any]] = None
@@ -463,7 +463,7 @@ class EmployeeResponse(BaseModel):
463
  photo_url: Optional[str]
464
  id_docs: Optional[List[Dict[str, Any]]]
465
  emergency_contact: Dict[str, Any]
466
- status: EmployeeStatus
467
  app_access: Dict[str, Any]
468
  location_settings: Dict[str, Any]
469
  created_by: str
 
8
 
9
  from app.constants.employee_types import (
10
  Designation,
11
+ stafftatus,
12
  LocationPrecision,
13
  IDDocumentType,
14
  DevicePlatform,
 
250
  emergency_contact: EmergencyContactSchema = Field(..., description="Emergency contact information")
251
 
252
  # Status
253
+ status: stafftatus = Field(
254
+ default=stafftatus.ONBOARDING,
255
  description="Employee status"
256
  )
257
 
 
414
  photo_url: Optional[str] = None
415
  id_docs: Optional[List[IDDocumentSchema]] = None
416
  emergency_contact: Optional[EmergencyContactSchema] = None
417
+ status: Optional[stafftatus] = None
418
  app_access: Optional[AppAccessSchema] = None
419
  location_settings: Optional[LocationSettingsSchema] = None
420
  metadata: Optional[Dict[str, Any]] = None
 
463
  photo_url: Optional[str]
464
  id_docs: Optional[List[Dict[str, Any]]]
465
  emergency_contact: Dict[str, Any]
466
+ status: stafftatus
467
  app_access: Dict[str, Any]
468
  location_settings: Dict[str, Any]
469
  created_by: str
app/{auth β†’ staff}/services/__init__.py RENAMED
File without changes
app/{employees β†’ staff}/services/service.py RENAMED
@@ -9,16 +9,16 @@ import logging
9
  import secrets
10
 
11
  from app.nosql import get_database
12
- from app.constants.collections import SCM_EMPLOYEES_COLLECTION
13
  from app.constants.employee_types import (
14
  Designation,
15
- EmployeeStatus,
16
  MANAGER_ALLOWED_DESIGNATIONS,
17
  MANAGER_REQUIRED_DESIGNATIONS,
18
  TWO_FA_REQUIRED_DESIGNATIONS,
19
  )
20
- from app.employees.models.model import EmployeeModel
21
- from app.employees.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
22
 
23
  # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
24
  logger = logging.getLogger(__name__)
@@ -32,7 +32,7 @@ def generate_user_id() -> str:
32
  return f"usr_{secrets.token_urlsafe(16)}"
33
 
34
 
35
- class EmployeeService:
36
  """Service class for employee operations"""
37
 
38
  @staticmethod
@@ -47,7 +47,7 @@ class EmployeeService:
47
  Employee document or None if not found
48
  """
49
  try:
50
- employee = await get_database()[SCM_EMPLOYEES_COLLECTION].find_one({"user_id": user_id})
51
  return employee
52
  except Exception as e:
53
  logger.error(f"Error fetching employee {user_id}", exc_info=e)
@@ -59,7 +59,7 @@ class EmployeeService:
59
  @staticmethod
60
  async def is_employee_code_unique(code: str) -> bool:
61
  """
62
- Check if employee_code is unique across all employees.
63
 
64
  Args:
65
  code: Employee code to check
@@ -68,7 +68,7 @@ class EmployeeService:
68
  True if unique, False otherwise
69
  """
70
  try:
71
- existing = await get_database()[SCM_EMPLOYEES_COLLECTION].find_one(
72
  {"employee_code": code.upper()}
73
  )
74
  return existing is None
@@ -82,7 +82,7 @@ class EmployeeService:
82
  @staticmethod
83
  async def is_email_unique(email: str, exclude_user_id: Optional[str] = None) -> bool:
84
  """
85
- Check if email is unique among active employees.
86
 
87
  Args:
88
  email: Email to check
@@ -95,16 +95,16 @@ class EmployeeService:
95
  query = {
96
  "email": email.lower(),
97
  "status": {"$in": [
98
- EmployeeStatus.ACTIVE.value,
99
- EmployeeStatus.ONBOARDING.value,
100
- EmployeeStatus.INACTIVE.value,
101
- EmployeeStatus.SUSPENDED.value
102
  ]}
103
  }
104
  if exclude_user_id:
105
  query["user_id"] = {"$ne": exclude_user_id}
106
 
107
- existing = await get_database()[SCM_EMPLOYEES_COLLECTION].find_one(query)
108
  return existing is None
109
  except Exception as e:
110
  logger.error(f"Error checking email uniqueness", exc_info=e)
@@ -116,7 +116,7 @@ class EmployeeService:
116
  @staticmethod
117
  async def is_phone_unique(phone: str, exclude_user_id: Optional[str] = None) -> bool:
118
  """
119
- Check if phone number is unique among active employees.
120
 
121
  Args:
122
  phone: Phone number to check
@@ -129,16 +129,16 @@ class EmployeeService:
129
  query = {
130
  "phone": phone,
131
  "status": {"$in": [
132
- EmployeeStatus.ACTIVE.value,
133
- EmployeeStatus.ONBOARDING.value,
134
- EmployeeStatus.INACTIVE.value,
135
- EmployeeStatus.SUSPENDED.value
136
  ]}
137
  }
138
  if exclude_user_id:
139
  query["user_id"] = {"$ne": exclude_user_id}
140
 
141
- existing = await get_database()[SCM_EMPLOYEES_COLLECTION].find_one(query)
142
  return existing is None
143
  except Exception as e:
144
  logger.error(f"Error checking phone uniqueness", exc_info=e)
@@ -166,7 +166,7 @@ class EmployeeService:
166
  HTTPException if validation fails
167
  """
168
  # Get manager
169
- manager = await EmployeeService.get_employee_by_id(manager_id)
170
  if not manager:
171
  raise HTTPException(
172
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -174,7 +174,7 @@ class EmployeeService:
174
  )
175
 
176
  # Manager must be active
177
- if manager.get("status") != EmployeeStatus.ACTIVE.value:
178
  raise HTTPException(
179
  status_code=status.HTTP_400_BAD_REQUEST,
180
  detail="manager must have active status"
@@ -212,7 +212,7 @@ class EmployeeService:
212
  user_id = payload.user_id or generate_user_id()
213
 
214
  # 2) Check employee_code uniqueness
215
- code_unique = await EmployeeService.is_employee_code_unique(payload.employee_code)
216
  if not code_unique:
217
  raise HTTPException(
218
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -220,7 +220,7 @@ class EmployeeService:
220
  )
221
 
222
  # 3) Check email uniqueness
223
- email_unique = await EmployeeService.is_email_unique(payload.email)
224
  if not email_unique:
225
  raise HTTPException(
226
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -228,7 +228,7 @@ class EmployeeService:
228
  )
229
 
230
  # 4) Check phone uniqueness
231
- phone_unique = await EmployeeService.is_phone_unique(payload.phone)
232
  if not phone_unique:
233
  raise HTTPException(
234
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -237,7 +237,7 @@ class EmployeeService:
237
 
238
  # 5) Validate manager if provided
239
  if payload.manager_id:
240
- await EmployeeService.validate_manager(
241
  payload.manager_id,
242
  payload.designation
243
  )
@@ -281,7 +281,7 @@ class EmployeeService:
281
  if "id_docs" in employee_dict and employee_dict["id_docs"]:
282
  employee_dict["id_docs"] = [dict(doc) for doc in employee_dict["id_docs"]]
283
 
284
- await get_database()[SCM_EMPLOYEES_COLLECTION].insert_one(employee_dict)
285
 
286
  logger.info(
287
  f"Created employee {user_id}",
@@ -324,7 +324,7 @@ class EmployeeService:
324
  HTTPException if employee not found or update fails
325
  """
326
  # Check employee exists
327
- existing = await EmployeeService.get_employee_by_id(user_id)
328
  if not existing:
329
  raise HTTPException(
330
  status_code=status.HTTP_404_NOT_FOUND,
@@ -341,7 +341,7 @@ class EmployeeService:
341
 
342
  # Validate email uniqueness if being updated
343
  if "email" in update_data:
344
- email_unique = await EmployeeService.is_email_unique(
345
  update_data["email"],
346
  exclude_user_id=user_id
347
  )
@@ -353,7 +353,7 @@ class EmployeeService:
353
 
354
  # Validate phone uniqueness if being updated
355
  if "phone" in update_data:
356
- phone_unique = await EmployeeService.is_phone_unique(
357
  update_data["phone"],
358
  exclude_user_id=user_id
359
  )
@@ -366,7 +366,7 @@ class EmployeeService:
366
  # Validate manager if being updated
367
  if "manager_id" in update_data and update_data["manager_id"]:
368
  designation = update_data.get("designation") or existing.get("designation")
369
- await EmployeeService.validate_manager(
370
  update_data["manager_id"],
371
  Designation(designation)
372
  )
@@ -393,7 +393,7 @@ class EmployeeService:
393
  update_data["id_docs"] = [dict(doc) for doc in update_data["id_docs"]]
394
 
395
  # Update in database
396
- result = await get_database()[SCM_EMPLOYEES_COLLECTION].update_one(
397
  {"user_id": user_id},
398
  {"$set": update_data}
399
  )
@@ -402,7 +402,7 @@ class EmployeeService:
402
  logger.warning(f"No changes made to employee {user_id}")
403
 
404
  # Fetch updated employee
405
- updated_employee = await EmployeeService.get_employee_by_id(user_id)
406
 
407
  logger.info(
408
  f"Updated employee {user_id}",
@@ -436,7 +436,7 @@ class EmployeeService:
436
  Raises:
437
  HTTPException if not found
438
  """
439
- employee = await EmployeeService.get_employee_by_id(user_id)
440
  if not employee:
441
  raise HTTPException(
442
  status_code=status.HTTP_404_NOT_FOUND,
@@ -459,7 +459,7 @@ class EmployeeService:
459
  HTTPException if not found
460
  """
461
  try:
462
- employee = await get_database()[SCM_EMPLOYEES_COLLECTION].find_one(
463
  {"employee_code": employee_code.upper()}
464
  )
465
  if not employee:
@@ -478,16 +478,16 @@ class EmployeeService:
478
  )
479
 
480
  @staticmethod
481
- async def list_employees(
482
  designation: Optional[Designation] = None,
483
  manager_id: Optional[str] = None,
484
- status_filter: Optional[EmployeeStatus] = None,
485
  region: Optional[str] = None,
486
  skip: int = 0,
487
  limit: int = 100
488
  ) -> List[EmployeeResponse]:
489
  """
490
- List employees with optional filters.
491
 
492
  Args:
493
  designation: Filter by designation
@@ -512,17 +512,17 @@ class EmployeeService:
512
  if region:
513
  query["region"] = region
514
 
515
- # Fetch employees
516
- cursor = get_database()[SCM_EMPLOYEES_COLLECTION].find(query).skip(skip).limit(limit)
517
- employees = await cursor.to_list(length=limit)
518
 
519
- return [EmployeeResponse(**emp) for emp in employees]
520
 
521
  except Exception as e:
522
- logger.error("Error listing employees", exc_info=e)
523
  raise HTTPException(
524
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
525
- detail="Error listing employees"
526
  )
527
 
528
  @staticmethod
@@ -541,7 +541,7 @@ class EmployeeService:
541
  HTTPException if not found or has active reports
542
  """
543
  # Check employee exists
544
- employee = await EmployeeService.get_employee_by_id(user_id)
545
  if not employee:
546
  raise HTTPException(
547
  status_code=status.HTTP_404_NOT_FOUND,
@@ -549,11 +549,11 @@ class EmployeeService:
549
  )
550
 
551
  # Check for active direct reports
552
- active_reports = await get_database()[SCM_EMPLOYEES_COLLECTION].find_one({
553
  "manager_id": user_id,
554
  "status": {"$in": [
555
- EmployeeStatus.ACTIVE.value,
556
- EmployeeStatus.ONBOARDING.value
557
  ]}
558
  })
559
 
@@ -566,11 +566,11 @@ class EmployeeService:
566
 
567
  try:
568
  # Soft delete - set status to terminated
569
- await get_database()[SCM_EMPLOYEES_COLLECTION].update_one(
570
  {"user_id": user_id},
571
  {
572
  "$set": {
573
- "status": EmployeeStatus.TERMINATED.value,
574
  "updated_at": datetime.utcnow().isoformat(),
575
  "updated_by": deleted_by
576
  }
 
9
  import secrets
10
 
11
  from app.nosql import get_database
12
+ from app.constants.collections import SCM_staff_COLLECTION
13
  from app.constants.employee_types import (
14
  Designation,
15
+ stafftatus,
16
  MANAGER_ALLOWED_DESIGNATIONS,
17
  MANAGER_REQUIRED_DESIGNATIONS,
18
  TWO_FA_REQUIRED_DESIGNATIONS,
19
  )
20
+ from app.staff.models.model import EmployeeModel
21
+ from app.staff.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
22
 
23
  # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
24
  logger = logging.getLogger(__name__)
 
32
  return f"usr_{secrets.token_urlsafe(16)}"
33
 
34
 
35
+ class staffervice:
36
  """Service class for employee operations"""
37
 
38
  @staticmethod
 
47
  Employee document or None if not found
48
  """
49
  try:
50
+ employee = await get_database()[SCM_staff_COLLECTION].find_one({"user_id": user_id})
51
  return employee
52
  except Exception as e:
53
  logger.error(f"Error fetching employee {user_id}", exc_info=e)
 
59
  @staticmethod
60
  async def is_employee_code_unique(code: str) -> bool:
61
  """
62
+ Check if employee_code is unique across all staff.
63
 
64
  Args:
65
  code: Employee code to check
 
68
  True if unique, False otherwise
69
  """
70
  try:
71
+ existing = await get_database()[SCM_staff_COLLECTION].find_one(
72
  {"employee_code": code.upper()}
73
  )
74
  return existing is None
 
82
  @staticmethod
83
  async def is_email_unique(email: str, exclude_user_id: Optional[str] = None) -> bool:
84
  """
85
+ Check if email is unique among active staff.
86
 
87
  Args:
88
  email: Email to check
 
95
  query = {
96
  "email": email.lower(),
97
  "status": {"$in": [
98
+ stafftatus.ACTIVE.value,
99
+ stafftatus.ONBOARDING.value,
100
+ stafftatus.INACTIVE.value,
101
+ stafftatus.SUSPENDED.value
102
  ]}
103
  }
104
  if exclude_user_id:
105
  query["user_id"] = {"$ne": exclude_user_id}
106
 
107
+ existing = await get_database()[SCM_staff_COLLECTION].find_one(query)
108
  return existing is None
109
  except Exception as e:
110
  logger.error(f"Error checking email uniqueness", exc_info=e)
 
116
  @staticmethod
117
  async def is_phone_unique(phone: str, exclude_user_id: Optional[str] = None) -> bool:
118
  """
119
+ Check if phone number is unique among active staff.
120
 
121
  Args:
122
  phone: Phone number to check
 
129
  query = {
130
  "phone": phone,
131
  "status": {"$in": [
132
+ stafftatus.ACTIVE.value,
133
+ stafftatus.ONBOARDING.value,
134
+ stafftatus.INACTIVE.value,
135
+ stafftatus.SUSPENDED.value
136
  ]}
137
  }
138
  if exclude_user_id:
139
  query["user_id"] = {"$ne": exclude_user_id}
140
 
141
+ existing = await get_database()[SCM_staff_COLLECTION].find_one(query)
142
  return existing is None
143
  except Exception as e:
144
  logger.error(f"Error checking phone uniqueness", exc_info=e)
 
166
  HTTPException if validation fails
167
  """
168
  # Get manager
169
+ manager = await staffervice.get_employee_by_id(manager_id)
170
  if not manager:
171
  raise HTTPException(
172
  status_code=status.HTTP_400_BAD_REQUEST,
 
174
  )
175
 
176
  # Manager must be active
177
+ if manager.get("status") != stafftatus.ACTIVE.value:
178
  raise HTTPException(
179
  status_code=status.HTTP_400_BAD_REQUEST,
180
  detail="manager must have active status"
 
212
  user_id = payload.user_id or generate_user_id()
213
 
214
  # 2) Check employee_code uniqueness
215
+ code_unique = await staffervice.is_employee_code_unique(payload.employee_code)
216
  if not code_unique:
217
  raise HTTPException(
218
  status_code=status.HTTP_400_BAD_REQUEST,
 
220
  )
221
 
222
  # 3) Check email uniqueness
223
+ email_unique = await staffervice.is_email_unique(payload.email)
224
  if not email_unique:
225
  raise HTTPException(
226
  status_code=status.HTTP_400_BAD_REQUEST,
 
228
  )
229
 
230
  # 4) Check phone uniqueness
231
+ phone_unique = await staffervice.is_phone_unique(payload.phone)
232
  if not phone_unique:
233
  raise HTTPException(
234
  status_code=status.HTTP_400_BAD_REQUEST,
 
237
 
238
  # 5) Validate manager if provided
239
  if payload.manager_id:
240
+ await staffervice.validate_manager(
241
  payload.manager_id,
242
  payload.designation
243
  )
 
281
  if "id_docs" in employee_dict and employee_dict["id_docs"]:
282
  employee_dict["id_docs"] = [dict(doc) for doc in employee_dict["id_docs"]]
283
 
284
+ await get_database()[SCM_staff_COLLECTION].insert_one(employee_dict)
285
 
286
  logger.info(
287
  f"Created employee {user_id}",
 
324
  HTTPException if employee not found or update fails
325
  """
326
  # Check employee exists
327
+ existing = await staffervice.get_employee_by_id(user_id)
328
  if not existing:
329
  raise HTTPException(
330
  status_code=status.HTTP_404_NOT_FOUND,
 
341
 
342
  # Validate email uniqueness if being updated
343
  if "email" in update_data:
344
+ email_unique = await staffervice.is_email_unique(
345
  update_data["email"],
346
  exclude_user_id=user_id
347
  )
 
353
 
354
  # Validate phone uniqueness if being updated
355
  if "phone" in update_data:
356
+ phone_unique = await staffervice.is_phone_unique(
357
  update_data["phone"],
358
  exclude_user_id=user_id
359
  )
 
366
  # Validate manager if being updated
367
  if "manager_id" in update_data and update_data["manager_id"]:
368
  designation = update_data.get("designation") or existing.get("designation")
369
+ await staffervice.validate_manager(
370
  update_data["manager_id"],
371
  Designation(designation)
372
  )
 
393
  update_data["id_docs"] = [dict(doc) for doc in update_data["id_docs"]]
394
 
395
  # Update in database
396
+ result = await get_database()[SCM_staff_COLLECTION].update_one(
397
  {"user_id": user_id},
398
  {"$set": update_data}
399
  )
 
402
  logger.warning(f"No changes made to employee {user_id}")
403
 
404
  # Fetch updated employee
405
+ updated_employee = await staffervice.get_employee_by_id(user_id)
406
 
407
  logger.info(
408
  f"Updated employee {user_id}",
 
436
  Raises:
437
  HTTPException if not found
438
  """
439
+ employee = await staffervice.get_employee_by_id(user_id)
440
  if not employee:
441
  raise HTTPException(
442
  status_code=status.HTTP_404_NOT_FOUND,
 
459
  HTTPException if not found
460
  """
461
  try:
462
+ employee = await get_database()[SCM_staff_COLLECTION].find_one(
463
  {"employee_code": employee_code.upper()}
464
  )
465
  if not employee:
 
478
  )
479
 
480
  @staticmethod
481
+ async def list_staff(
482
  designation: Optional[Designation] = None,
483
  manager_id: Optional[str] = None,
484
+ status_filter: Optional[stafftatus] = None,
485
  region: Optional[str] = None,
486
  skip: int = 0,
487
  limit: int = 100
488
  ) -> List[EmployeeResponse]:
489
  """
490
+ List staff with optional filters.
491
 
492
  Args:
493
  designation: Filter by designation
 
512
  if region:
513
  query["region"] = region
514
 
515
+ # Fetch staff
516
+ cursor = get_database()[SCM_staff_COLLECTION].find(query).skip(skip).limit(limit)
517
+ staff = await cursor.to_list(length=limit)
518
 
519
+ return [EmployeeResponse(**emp) for emp in staff]
520
 
521
  except Exception as e:
522
+ logger.error("Error listing staff", exc_info=e)
523
  raise HTTPException(
524
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
525
+ detail="Error listing staff"
526
  )
527
 
528
  @staticmethod
 
541
  HTTPException if not found or has active reports
542
  """
543
  # Check employee exists
544
+ employee = await staffervice.get_employee_by_id(user_id)
545
  if not employee:
546
  raise HTTPException(
547
  status_code=status.HTTP_404_NOT_FOUND,
 
549
  )
550
 
551
  # Check for active direct reports
552
+ active_reports = await get_database()[SCM_staff_COLLECTION].find_one({
553
  "manager_id": user_id,
554
  "status": {"$in": [
555
+ stafftatus.ACTIVE.value,
556
+ stafftatus.ONBOARDING.value
557
  ]}
558
  })
559
 
 
566
 
567
  try:
568
  # Soft delete - set status to terminated
569
+ await get_database()[SCM_staff_COLLECTION].update_one(
570
  {"user_id": user_id},
571
  {
572
  "$set": {
573
+ "status": stafftatus.TERMINATED.value,
574
  "updated_at": datetime.utcnow().isoformat(),
575
  "updated_by": deleted_by
576
  }
app/utils/db_init.py CHANGED
@@ -7,7 +7,7 @@ import logging
7
  from app.nosql import get_database
8
  from app.constants.collections import (
9
  SCM_MERCHANTS_COLLECTION,
10
- SCM_EMPLOYEES_COLLECTION,
11
  SCM_ROLES_COLLECTION,
12
  SCM_AUTH_LOGS_COLLECTION,
13
  SCM_SALES_ORDERS_COLLECTION,
@@ -34,16 +34,16 @@ async def create_indexes():
34
  await get_database()[SCM_MERCHANTS_COLLECTION].create_index("contact_email")
35
  logger.info(f"Created indexes for {SCM_MERCHANTS_COLLECTION}")
36
 
37
- # Employees collection indexes
38
- await get_database()[SCM_EMPLOYEES_COLLECTION].create_index("associate_id", unique=True)
39
- await get_database()[SCM_EMPLOYEES_COLLECTION].create_index("email", unique=True)
40
- await get_database()[SCM_EMPLOYEES_COLLECTION].create_index("mobile", unique=True)
41
- await get_database()[SCM_EMPLOYEES_COLLECTION].create_index("merchant_id")
42
- await get_database()[SCM_EMPLOYEES_COLLECTION].create_index([
43
  ("merchant_id", 1),
44
  ("role_id", 1)
45
  ])
46
- logger.info(f"Created indexes for {SCM_EMPLOYEES_COLLECTION}")
47
 
48
  # Roles collection indexes
49
  await get_database()[SCM_ROLES_COLLECTION].create_index([
@@ -100,7 +100,7 @@ async def drop_indexes():
100
  logger.warning("Dropping all database indexes")
101
 
102
  await get_database()[SCM_MERCHANTS_COLLECTION].drop_indexes()
103
- await get_database()[SCM_EMPLOYEES_COLLECTION].drop_indexes()
104
  await get_database()[SCM_ROLES_COLLECTION].drop_indexes()
105
  await get_database()[SCM_AUTH_LOGS_COLLECTION].drop_indexes()
106
  await get_database()[SCM_SALES_ORDERS_COLLECTION].drop_indexes()
 
7
  from app.nosql import get_database
8
  from app.constants.collections import (
9
  SCM_MERCHANTS_COLLECTION,
10
+ SCM_staff_COLLECTION,
11
  SCM_ROLES_COLLECTION,
12
  SCM_AUTH_LOGS_COLLECTION,
13
  SCM_SALES_ORDERS_COLLECTION,
 
34
  await get_database()[SCM_MERCHANTS_COLLECTION].create_index("contact_email")
35
  logger.info(f"Created indexes for {SCM_MERCHANTS_COLLECTION}")
36
 
37
+ # staff collection indexes
38
+ await get_database()[SCM_staff_COLLECTION].create_index("associate_id", unique=True)
39
+ await get_database()[SCM_staff_COLLECTION].create_index("email", unique=True)
40
+ await get_database()[SCM_staff_COLLECTION].create_index("mobile", unique=True)
41
+ await get_database()[SCM_staff_COLLECTION].create_index("merchant_id")
42
+ await get_database()[SCM_staff_COLLECTION].create_index([
43
  ("merchant_id", 1),
44
  ("role_id", 1)
45
  ])
46
+ logger.info(f"Created indexes for {SCM_staff_COLLECTION}")
47
 
48
  # Roles collection indexes
49
  await get_database()[SCM_ROLES_COLLECTION].create_index([
 
100
  logger.warning("Dropping all database indexes")
101
 
102
  await get_database()[SCM_MERCHANTS_COLLECTION].drop_indexes()
103
+ await get_database()[SCM_staff_COLLECTION].drop_indexes()
104
  await get_database()[SCM_ROLES_COLLECTION].drop_indexes()
105
  await get_database()[SCM_AUTH_LOGS_COLLECTION].drop_indexes()
106
  await get_database()[SCM_SALES_ORDERS_COLLECTION].drop_indexes()
tests/test_employee_schemas.py CHANGED
@@ -15,7 +15,7 @@ from app.schemas.employee_schema import (
15
  )
16
  from app.constants.employee_types import (
17
  Designation,
18
- EmployeeStatus,
19
  LocationPrecision,
20
  IDDocumentType,
21
  )
@@ -200,7 +200,7 @@ class TestEmployeeCreate:
200
  assert "E.164" in str(exc_info.value)
201
 
202
  def test_age_validation_too_young(self):
203
- """Test that employees under 18 are rejected"""
204
  data = self.get_valid_employee_data()
205
  data["dob"] = date.today() - timedelta(days=17*365) # 17 years old
206
  with pytest.raises(ValidationError) as exc_info:
@@ -315,10 +315,10 @@ class TestEmployeeUpdate:
315
  """Test partial update with only some fields"""
316
  update = EmployeeUpdate(
317
  first_name="Updated Name",
318
- status=EmployeeStatus.ACTIVE
319
  )
320
  assert update.first_name == "Updated Name"
321
- assert update.status == EmployeeStatus.ACTIVE
322
  assert update.email is None # Not provided
323
 
324
  def test_email_normalization(self):
 
15
  )
16
  from app.constants.employee_types import (
17
  Designation,
18
+ stafftatus,
19
  LocationPrecision,
20
  IDDocumentType,
21
  )
 
200
  assert "E.164" in str(exc_info.value)
201
 
202
  def test_age_validation_too_young(self):
203
+ """Test that staff under 18 are rejected"""
204
  data = self.get_valid_employee_data()
205
  data["dob"] = date.today() - timedelta(days=17*365) # 17 years old
206
  with pytest.raises(ValidationError) as exc_info:
 
315
  """Test partial update with only some fields"""
316
  update = EmployeeUpdate(
317
  first_name="Updated Name",
318
+ status=stafftatus.ACTIVE
319
  )
320
  assert update.first_name == "Updated Name"
321
+ assert update.status == stafftatus.ACTIVE
322
  assert update.email is None # Not provided
323
 
324
  def test_email_normalization(self):
tests/test_properties_data_models.py CHANGED
@@ -4,7 +4,7 @@ Feature: scm-authentication
4
  """
5
  import pytest
6
  from hypothesis import given, strategies as st, settings
7
- from app.constants.collections import SCM_MERCHANTS_COLLECTION, SCM_EMPLOYEES_COLLECTION
8
 
9
 
10
  # Strategies for generating test data
@@ -35,14 +35,14 @@ async def test_property_associate_id_uniqueness_per_merchant(
35
  """
36
  Feature: scm-authentication, Property 8: Associate ID uniqueness per merchant
37
 
38
- For any two employees within the same merchant, their associate_ids should be unique.
39
  Validates: Requirements 1.1
40
  """
41
  # Skip if merchant_ids are the same (we want to test within same merchant)
42
  if merchant_id1 == merchant_id2:
43
  return
44
 
45
- # Create two employees with different associate_ids in the same merchant
46
  employee1 = {
47
  "associate_id": f"{merchant_id1}_e001",
48
  "merchant_id": merchant_id1,
@@ -63,17 +63,17 @@ async def test_property_associate_id_uniqueness_per_merchant(
63
  "merchant_type": "salon"
64
  }
65
 
66
- # Insert both employees
67
- await test_db[SCM_EMPLOYEES_COLLECTION].insert_one(employee1)
68
- await test_db[SCM_EMPLOYEES_COLLECTION].insert_one(employee2)
69
 
70
  # Verify both exist
71
- count = await test_db[SCM_EMPLOYEES_COLLECTION].count_documents({"merchant_id": merchant_id1})
72
  assert count == 2
73
 
74
  # Verify associate_ids are unique
75
- employees = await test_db[SCM_EMPLOYEES_COLLECTION].find({"merchant_id": merchant_id1}).to_list(None)
76
- associate_ids = [emp["associate_id"] for emp in employees]
77
  assert len(associate_ids) == len(set(associate_ids)), "Associate IDs must be unique within a merchant"
78
 
79
 
 
4
  """
5
  import pytest
6
  from hypothesis import given, strategies as st, settings
7
+ from app.constants.collections import SCM_MERCHANTS_COLLECTION, SCM_staff_COLLECTION
8
 
9
 
10
  # Strategies for generating test data
 
35
  """
36
  Feature: scm-authentication, Property 8: Associate ID uniqueness per merchant
37
 
38
+ For any two staff within the same merchant, their associate_ids should be unique.
39
  Validates: Requirements 1.1
40
  """
41
  # Skip if merchant_ids are the same (we want to test within same merchant)
42
  if merchant_id1 == merchant_id2:
43
  return
44
 
45
+ # Create two staff with different associate_ids in the same merchant
46
  employee1 = {
47
  "associate_id": f"{merchant_id1}_e001",
48
  "merchant_id": merchant_id1,
 
63
  "merchant_type": "salon"
64
  }
65
 
66
+ # Insert both staff
67
+ await test_db[SCM_staff_COLLECTION].insert_one(employee1)
68
+ await test_db[SCM_staff_COLLECTION].insert_one(employee2)
69
 
70
  # Verify both exist
71
+ count = await test_db[SCM_staff_COLLECTION].count_documents({"merchant_id": merchant_id1})
72
  assert count == 2
73
 
74
  # Verify associate_ids are unique
75
+ staff = await test_db[SCM_staff_COLLECTION].find({"merchant_id": merchant_id1}).to_list(None)
76
+ associate_ids = [emp["associate_id"] for emp in staff]
77
  assert len(associate_ids) == len(set(associate_ids)), "Associate IDs must be unique within a merchant"
78
 
79