MukeshKapoor25 commited on
Commit
e4346d3
Β·
1 Parent(s): a85187e

feat(taxonomy): Implement complete taxonomy API with merchant isolation and JWT authentication

Browse files

- Add merchant_id extraction and validation to TokenUser model in auth dependencies
- Implement get_taxonomy_by_id endpoint with proper access control and merchant data isolation
- Add merchant_id as required field in CreateUserRequest schema
- Enhance taxonomy service with comprehensive error handling and logging
- Update taxonomy model with merchant-aware query methods
- Add TAXONOMY_API_COMPLETE_GUIDE.md documentation for API implementation
- Add test_complete_taxonomy_implementation.py for end-to-end testing
- Implement super admin bypass for cross-merchant taxonomy access
- Add correlation ID tracking and structured logging throughout taxonomy operations
- Validate merchant access for non-super-admin users on all taxonomy endpoints

TAXONOMY_API_COMPLETE_GUIDE.md ADDED
File without changes
app/dependencies/auth.py CHANGED
@@ -22,6 +22,7 @@ class TokenUser(BaseModel):
22
  user_id: str
23
  username: str
24
  role: str
 
25
  metadata: Optional[dict] = None
26
 
27
  def has_role(self, *roles: str) -> bool:
@@ -68,9 +69,10 @@ async def get_current_user(
68
  user_id: str = payload.get("sub")
69
  username: str = payload.get("username")
70
  role: str = payload.get("role")
 
71
  metadata: dict = payload.get("metadata")
72
 
73
- if user_id is None or username is None:
74
  raise credentials_exception
75
 
76
  # Create TokenUser from payload
@@ -78,6 +80,7 @@ async def get_current_user(
78
  user_id=user_id,
79
  username=username,
80
  role=role or "user",
 
81
  metadata=metadata
82
  )
83
 
@@ -160,14 +163,16 @@ async def get_optional_user(
160
  user_id: str = payload.get("sub")
161
  username: str = payload.get("username")
162
  role: str = payload.get("role")
 
163
 
164
- if user_id is None or username is None:
165
  return None
166
 
167
  return TokenUser(
168
  user_id=user_id,
169
  username=username,
170
- role=role or "user"
 
171
  )
172
 
173
  except Exception:
 
22
  user_id: str
23
  username: str
24
  role: str
25
+ merchant_id: str
26
  metadata: Optional[dict] = None
27
 
28
  def has_role(self, *roles: str) -> bool:
 
69
  user_id: str = payload.get("sub")
70
  username: str = payload.get("username")
71
  role: str = payload.get("role")
72
+ merchant_id: str = payload.get("merchant_id")
73
  metadata: dict = payload.get("metadata")
74
 
75
+ if user_id is None or username is None or merchant_id is None:
76
  raise credentials_exception
77
 
78
  # Create TokenUser from payload
 
80
  user_id=user_id,
81
  username=username,
82
  role=role or "user",
83
+ merchant_id=merchant_id,
84
  metadata=metadata
85
  )
86
 
 
163
  user_id: str = payload.get("sub")
164
  username: str = payload.get("username")
165
  role: str = payload.get("role")
166
+ merchant_id: str = payload.get("merchant_id")
167
 
168
+ if user_id is None or username is None or merchant_id is None:
169
  return None
170
 
171
  return TokenUser(
172
  user_id=user_id,
173
  username=username,
174
+ role=role or "user",
175
+ merchant_id=merchant_id
176
  )
177
 
178
  except Exception:
app/system_users/schemas/schema.py CHANGED
@@ -41,10 +41,10 @@ class CreateUserRequest(BaseModel):
41
 
42
  username: str = Field(..., description="Unique username", min_length=3, max_length=50)
43
  email: EmailStr = Field(..., description="Email address")
 
44
  password: str = Field(..., description="Password", min_length=8, max_length=100)
45
  full_name: str = Field(..., description="Full name", min_length=1, max_length=100)
46
  role_id: str = Field(..., description="Role identifier")
47
- merchant_id: Optional[str] = Field(None, description="Merchant identifier")
48
  is_active: bool = Field(default=True, description="Active flag")
49
  metadata: Optional[Dict[str, str]] = Field(None, description="Optional metadata")
50
 
 
41
 
42
  username: str = Field(..., description="Unique username", min_length=3, max_length=50)
43
  email: EmailStr = Field(..., description="Email address")
44
+ merchant_id: str = Field(..., description="Merchant identifier")
45
  password: str = Field(..., description="Password", min_length=8, max_length=100)
46
  full_name: str = Field(..., description="Full name", min_length=1, max_length=100)
47
  role_id: str = Field(..., description="Role identifier")
 
48
  is_active: bool = Field(default=True, description="Active flag")
49
  metadata: Optional[Dict[str, str]] = Field(None, description="Optional metadata")
50
 
app/taxonomy/controllers/router.py CHANGED
@@ -371,6 +371,123 @@ async def list_taxonomy(
371
  raise TaxonomyErrorHandler.convert_to_http_exception(
372
  e, correlation_id=str(uuid.uuid4())
373
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  @router.delete("/{taxonomy_id}", status_code=status.HTTP_200_OK)
375
  async def delete_taxonomy_entry(
376
  taxonomy_id: str = Path(..., description="Taxonomy document ID to delete"),
 
371
  raise TaxonomyErrorHandler.convert_to_http_exception(
372
  e, correlation_id=str(uuid.uuid4())
373
  )
374
+ @router.get("/{taxonomy_id}", status_code=status.HTTP_200_OK)
375
+ async def get_taxonomy_by_id(
376
+ taxonomy_id: str = Path(..., description="Taxonomy document ID to retrieve"),
377
+ current_user: TokenUser = Depends(get_current_active_user),
378
+ taxonomy_service: TaxonomyService = Depends(get_taxonomy_service)
379
+ ) -> Dict[str, Any]:
380
+ """
381
+ Get a specific taxonomy document by ID.
382
+
383
+ This endpoint retrieves a complete taxonomy document for a merchant by its unique identifier.
384
+ Implements proper merchant data isolation and access control.
385
+
386
+ **Authentication Required**: Valid JWT token with merchant_id in metadata
387
+
388
+ **Path Parameters**:
389
+ - taxonomy_id: The unique identifier of the taxonomy document
390
+
391
+ **Note**: merchant_id is extracted from JWT token for validation
392
+
393
+ **Returns**: Complete taxonomy document with all taxonomy categories
394
+
395
+ **Validates Requirements**: 4.1, 4.2, 6.1
396
+ """
397
+ try:
398
+ # Set logging context
399
+ correlation_id = str(uuid.uuid4())
400
+ logger.set_context(
401
+ correlation_id=correlation_id,
402
+ user_id=current_user.user_id,
403
+ operation="get_taxonomy_by_id"
404
+ )
405
+
406
+ # Extract merchant_id from JWT token
407
+ merchant_id = get_user_merchant_id(current_user)
408
+
409
+ # Log the operation start
410
+ logger.log_operation_start(
411
+ "get_taxonomy_by_id",
412
+ details={
413
+ "taxonomy_id": taxonomy_id,
414
+ "merchant_id": merchant_id,
415
+ "username": current_user.username,
416
+ "is_super_admin": current_user.is_super_admin()
417
+ },
418
+ merchant_id=merchant_id,
419
+ user_id=current_user.user_id
420
+ )
421
+
422
+ # Get taxonomy document from database
423
+ from app.taxonomy.models.model import TaxonomyModel
424
+ model = TaxonomyModel()
425
+
426
+ # For super admin, don't filter by merchant_id to allow access to all merchants
427
+ filter_merchant_id = merchant_id if not current_user.is_super_admin() else None
428
+
429
+ document = await model.get_taxonomy_by_id(
430
+ taxonomy_id=taxonomy_id,
431
+ merchant_id=filter_merchant_id
432
+ )
433
+
434
+ if not document:
435
+ taxonomy_error = not_found_error(
436
+ resource_type="taxonomy document",
437
+ resource_id=taxonomy_id,
438
+ merchant_id=merchant_id,
439
+ user_id=current_user.user_id
440
+ )
441
+ raise TaxonomyErrorHandler.convert_to_http_exception(taxonomy_error)
442
+
443
+ # Validate merchant access for non-super-admin users
444
+ if not current_user.is_super_admin() and document.get("merchant_id") != merchant_id:
445
+ taxonomy_error = access_denied_error(
446
+ message="Access denied for taxonomy document",
447
+ merchant_id=merchant_id,
448
+ user_id=current_user.user_id
449
+ )
450
+ raise TaxonomyErrorHandler.convert_to_http_exception(taxonomy_error)
451
+
452
+ # Log successful operation
453
+ logger.log_operation_success(
454
+ "get_taxonomy_by_id",
455
+ result_summary={
456
+ "taxonomy_id": taxonomy_id,
457
+ "merchant_id": document.get("merchant_id"),
458
+ "has_data": bool(document)
459
+ }
460
+ )
461
+
462
+ # Return standardized SCM response format
463
+ return {
464
+ "success": True,
465
+ "message": "Taxonomy document retrieved successfully",
466
+ "data": document,
467
+ "metadata": {
468
+ "taxonomy_id": taxonomy_id,
469
+ "merchant_id": document.get("merchant_id"),
470
+ "timestamp": document.get("updated_at") or document.get("created_at")
471
+ }
472
+ }
473
+
474
+ except TaxonomyError as e:
475
+ # Handle structured taxonomy errors
476
+ raise TaxonomyErrorHandler.convert_to_http_exception(
477
+ e, correlation_id=e.correlation_id
478
+ )
479
+
480
+ except HTTPException:
481
+ # Re-raise HTTP exceptions
482
+ raise
483
+
484
+ except Exception as e:
485
+ # Handle unexpected errors with proper logging and structured response
486
+ raise TaxonomyErrorHandler.convert_to_http_exception(
487
+ e, correlation_id=str(uuid.uuid4())
488
+ )
489
+
490
+
491
  @router.delete("/{taxonomy_id}", status_code=status.HTTP_200_OK)
492
  async def delete_taxonomy_entry(
493
  taxonomy_id: str = Path(..., description="Taxonomy document ID to delete"),
app/taxonomy/models/model.py CHANGED
@@ -482,7 +482,7 @@ class TaxonomyModel:
482
  "job_role", "specializations", "languages", "customer_group",
483
  "pos_tender_modes", "payment_types", "payment_methods",
484
  "asset_location", "asset_category", "stock_bin_location",
485
- "branch_types", "uom_conversions"
486
  ]
487
 
488
  # Find fields that have data
 
482
  "job_role", "specializations", "languages", "customer_group",
483
  "pos_tender_modes", "payment_types", "payment_methods",
484
  "asset_location", "asset_category", "stock_bin_location",
485
+ "branch_types", "expense_types", "uom_conversions"
486
  ]
487
 
488
  # Find fields that have data
app/taxonomy/schemas/schema.py CHANGED
@@ -64,79 +64,88 @@ class TaxonomyInfo(BaseModel):
64
  """
65
  Comprehensive taxonomy information schema for merchant-specific categorization.
66
  Supports all taxonomy categories including product, employee, customer, and operational taxonomies.
 
67
  """
68
  merchant_id: Optional[str] = Field(None, description="Merchant identifier (set from JWT)")
69
 
70
  # Product Taxonomies
71
  brands: Optional[List[str]] = Field(
72
  default_factory=list,
73
- description="Product brands taxonomy"
74
  )
75
  categories: Optional[List[str]] = Field(
76
  default_factory=list,
77
- description="Product categories taxonomy"
78
  )
79
  lines: Optional[List[str]] = Field(
80
  default_factory=list,
81
- description="Product lines taxonomy"
82
  )
83
  classes: Optional[List[str]] = Field(
84
  default_factory=list,
85
- description="Product classes taxonomy"
86
  )
87
  subcategories: Optional[Dict[str, List[str]]] = Field(
88
  default_factory=dict,
89
- description="Product subcategories mapped to parent categories"
90
  )
91
 
92
  # Employee Taxonomies
93
  job_role: Optional[List[str]] = Field(
94
  default_factory=list,
95
- description="Employee job roles taxonomy"
96
  )
97
  specializations: Optional[List[str]] = Field(
98
  default_factory=list,
99
- description="Employee specializations taxonomy"
100
  )
101
  languages: Optional[List[str]] = Field(
102
  default_factory=list,
103
- description="Languages taxonomy"
104
  )
105
 
106
  # Customer Taxonomies
107
  customer_group: Optional[List[str]] = Field(
108
  default_factory=list,
109
- description="Customer group taxonomy"
110
  )
111
 
112
- # Operational Taxonomies
113
  pos_tender_modes: Optional[List[str]] = Field(
114
  default_factory=list,
115
- description="POS tender modes taxonomy"
116
  )
117
  payment_types: Optional[List[str]] = Field(
118
  default_factory=list,
119
- description="Payment types taxonomy"
120
  )
121
  payment_methods: Optional[List[str]] = Field(
122
  default_factory=list,
123
- description="Payment methods taxonomy"
124
  )
 
 
125
  asset_location: Optional[List[str]] = Field(
126
  default_factory=list,
127
- description="Asset location taxonomy"
128
  )
129
  asset_category: Optional[List[str]] = Field(
130
  default_factory=list,
131
- description="Asset category taxonomy"
132
  )
133
  stock_bin_location: Optional[List[str]] = Field(
134
  default_factory=list,
135
- description="Stock bin location taxonomy"
136
  )
 
 
137
  branch_types: Optional[List[str]] = Field(
138
  default_factory=list,
139
- description="Branch types taxonomy"
 
 
 
 
140
  )
141
 
142
  # UOM Conversions
@@ -184,7 +193,7 @@ class TaxonomyInfo(BaseModel):
184
  @validator('brands', 'categories', 'lines', 'classes', 'job_role', 'specializations',
185
  'languages', 'customer_group', 'pos_tender_modes', 'payment_types',
186
  'payment_methods', 'asset_location', 'asset_category', 'stock_bin_location',
187
- 'branch_types')
188
  def validate_string_lists(cls, v):
189
  if v is None:
190
  return []
@@ -263,7 +272,7 @@ class TaxonomyListRequest(BaseModel):
263
  'job_role', 'specializations', 'languages', 'customer_group',
264
  'pos_tender_modes', 'payment_types', 'payment_methods',
265
  'asset_location', 'asset_category', 'stock_bin_location',
266
- 'branch_types', 'uom_conversions'
267
  }
268
 
269
  clean_type = v.strip()
 
64
  """
65
  Comprehensive taxonomy information schema for merchant-specific categorization.
66
  Supports all taxonomy categories including product, employee, customer, and operational taxonomies.
67
+ Based on the complete taxonomy structure from the provided JSON example.
68
  """
69
  merchant_id: Optional[str] = Field(None, description="Merchant identifier (set from JWT)")
70
 
71
  # Product Taxonomies
72
  brands: Optional[List[str]] = Field(
73
  default_factory=list,
74
+ description="Product brands taxonomy (e.g., L'OrΓ©al Professional, Schwarzkopf, Kerastase)"
75
  )
76
  categories: Optional[List[str]] = Field(
77
  default_factory=list,
78
+ description="Product categories taxonomy (e.g., Hair, Skin, Nails, Spa, Makeup)"
79
  )
80
  lines: Optional[List[str]] = Field(
81
  default_factory=list,
82
+ description="Product lines taxonomy (e.g., Hair Care, Hair Styling, Facials)"
83
  )
84
  classes: Optional[List[str]] = Field(
85
  default_factory=list,
86
+ description="Product classes taxonomy (e.g., Premium, Luxury, Basic, VIP)"
87
  )
88
  subcategories: Optional[Dict[str, List[str]]] = Field(
89
  default_factory=dict,
90
+ description="Product subcategories mapped to parent categories (e.g., Hair: [Hair Cut, Hair Color])"
91
  )
92
 
93
  # Employee Taxonomies
94
  job_role: Optional[List[str]] = Field(
95
  default_factory=list,
96
+ description="Employee job roles taxonomy (e.g., Senior Stylist, Director, Massage therapist)"
97
  )
98
  specializations: Optional[List[str]] = Field(
99
  default_factory=list,
100
+ description="Employee specializations taxonomy (e.g., Hairdresser, Nail technician, Esthetician)"
101
  )
102
  languages: Optional[List[str]] = Field(
103
  default_factory=list,
104
+ description="Languages taxonomy (e.g., English, Hindi, Tamil)"
105
  )
106
 
107
  # Customer Taxonomies
108
  customer_group: Optional[List[str]] = Field(
109
  default_factory=list,
110
+ description="Customer group taxonomy (e.g., VIP, Regular, Premium, Enterprise, Gold)"
111
  )
112
 
113
+ # Operational Taxonomies - POS & Payment
114
  pos_tender_modes: Optional[List[str]] = Field(
115
  default_factory=list,
116
+ description="POS tender modes taxonomy (e.g., Cash, Credit Card, Debit Card, Mobile Payment)"
117
  )
118
  payment_types: Optional[List[str]] = Field(
119
  default_factory=list,
120
+ description="Payment types taxonomy (e.g., Cash and Carry, Credit)"
121
  )
122
  payment_methods: Optional[List[str]] = Field(
123
  default_factory=list,
124
+ description="Payment methods taxonomy (e.g., Bank Transfer, Cash, Cheque)"
125
  )
126
+
127
+ # Operational Taxonomies - Asset & Inventory
128
  asset_location: Optional[List[str]] = Field(
129
  default_factory=list,
130
+ description="Asset location taxonomy (e.g., Store Floor, Warehouse, Back Office)"
131
  )
132
  asset_category: Optional[List[str]] = Field(
133
  default_factory=list,
134
+ description="Asset category taxonomy (e.g., Electronics, Furniture, Equipment)"
135
  )
136
  stock_bin_location: Optional[List[str]] = Field(
137
  default_factory=list,
138
+ description="Stock bin location taxonomy (e.g., A1-01, B2-15, C3-07, Receiving)"
139
  )
140
+
141
+ # Operational Taxonomies - Business Structure
142
  branch_types: Optional[List[str]] = Field(
143
  default_factory=list,
144
+ description="Branch types taxonomy (e.g., Flagship, Outlet, Pop-up, Franchise)"
145
+ )
146
+ expense_types: Optional[List[str]] = Field(
147
+ default_factory=list,
148
+ description="Expense types taxonomy (e.g., Travel, Office Supplies)"
149
  )
150
 
151
  # UOM Conversions
 
193
  @validator('brands', 'categories', 'lines', 'classes', 'job_role', 'specializations',
194
  'languages', 'customer_group', 'pos_tender_modes', 'payment_types',
195
  'payment_methods', 'asset_location', 'asset_category', 'stock_bin_location',
196
+ 'branch_types', 'expense_types')
197
  def validate_string_lists(cls, v):
198
  if v is None:
199
  return []
 
272
  'job_role', 'specializations', 'languages', 'customer_group',
273
  'pos_tender_modes', 'payment_types', 'payment_methods',
274
  'asset_location', 'asset_category', 'stock_bin_location',
275
+ 'branch_types', 'expense_types', 'uom_conversions'
276
  }
277
 
278
  clean_type = v.strip()
app/taxonomy/services/service.py CHANGED
@@ -180,7 +180,8 @@ class TaxonomyService:
180
  "brands", "categories", "lines", "classes", "job_role",
181
  "specializations", "languages", "customer_group",
182
  "pos_tender_modes", "payment_types", "payment_methods",
183
- "asset_location", "asset_category", "stock_bin_location", "branch_types"
 
184
  ]
185
 
186
  for field in list_fields:
@@ -528,7 +529,7 @@ class TaxonomyService:
528
  "job_role", "specializations", "languages", "customer_group",
529
  "pos_tender_modes", "payment_types", "payment_methods",
530
  "asset_location", "asset_category", "stock_bin_location",
531
- "branch_types", "uom_conversions"
532
  ]
533
 
534
  for field in taxonomy_fields:
@@ -651,7 +652,8 @@ class TaxonomyService:
651
  "customer_group",
652
  # Operational taxonomies
653
  "pos_tender_modes", "payment_types", "payment_methods",
654
- "asset_location", "asset_category", "stock_bin_location", "branch_types",
 
655
  # UOM conversions
656
  "uom_conversions"
657
  }
@@ -709,7 +711,8 @@ class TaxonomyService:
709
  "customer_group",
710
  # Operational taxonomies
711
  "pos_tender_modes", "payment_types", "payment_methods",
712
- "asset_location", "asset_category", "stock_bin_location", "branch_types",
 
713
  # UOM conversions
714
  "uom_conversions"
715
  }
 
180
  "brands", "categories", "lines", "classes", "job_role",
181
  "specializations", "languages", "customer_group",
182
  "pos_tender_modes", "payment_types", "payment_methods",
183
+ "asset_location", "asset_category", "stock_bin_location",
184
+ "branch_types", "expense_types"
185
  ]
186
 
187
  for field in list_fields:
 
529
  "job_role", "specializations", "languages", "customer_group",
530
  "pos_tender_modes", "payment_types", "payment_methods",
531
  "asset_location", "asset_category", "stock_bin_location",
532
+ "branch_types", "expense_types", "uom_conversions"
533
  ]
534
 
535
  for field in taxonomy_fields:
 
652
  "customer_group",
653
  # Operational taxonomies
654
  "pos_tender_modes", "payment_types", "payment_methods",
655
+ "asset_location", "asset_category", "stock_bin_location",
656
+ "branch_types", "expense_types",
657
  # UOM conversions
658
  "uom_conversions"
659
  }
 
711
  "customer_group",
712
  # Operational taxonomies
713
  "pos_tender_modes", "payment_types", "payment_methods",
714
+ "asset_location", "asset_category", "stock_bin_location",
715
+ "branch_types", "expense_types",
716
  # UOM conversions
717
  "uom_conversions"
718
  }
test_complete_taxonomy_implementation.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Complete end-to-end taxonomy implementation test script.
4
+ Tests all taxonomy categories from the provided JSON structure.
5
+
6
+ This script demonstrates:
7
+ 1. Creating taxonomy data with all categories
8
+ 2. Using projection lists for performance optimization
9
+ 3. Filtering by specific taxonomy types
10
+ 4. UOM conversions management
11
+ 5. CRUD operations on taxonomy data
12
+
13
+ Based on the JSON structure:
14
+ {
15
+ "merchant_id": "company_cuatro_beauty_ltd",
16
+ "brands": ["L'OrΓ©al Professional", "Schwarzkopf", "Kerastase", ...],
17
+ "categories": ["Hair", "Skin", "Nails", "Spa", "Makeup", ...],
18
+ "lines": ["Hair Care", "Hair Styling", "Facials", ...],
19
+ "classes": ["Premium", "Luxury", "Basic", "VIP"],
20
+ "subcategories": {"Hair": ["Hair Cut", "Hair Color", ...], ...},
21
+ "specializations": ["Hairdresser", "Nail technician", ...],
22
+ "job_role": ["Senior Stylist", "Director", ...],
23
+ "languages": ["English", "Hindi", "Tamil"],
24
+ "customer_group": ["VIP", "Regular", "Premium", ...],
25
+ "pos_tender_modes": ["Cash", "Credit Card", ...],
26
+ "payment_types": ["Cash and Carry", "Credit"],
27
+ "payment_methods": ["Bank Transfer", "Cash", "Cheque"],
28
+ "asset_location": ["Store Floor", "Warehouse", ...],
29
+ "asset_category": ["Electronics", "Furniture", "Equipment"],
30
+ "stock_bin_location": ["A1-01", "B2-15", ...],
31
+ "branch_types": ["Flagship", "Outlet", "Pop-up", "Franchise"],
32
+ "expense_types": ["Travel", "Office Supplies"],
33
+ "uom_conversions": [...]
34
+ }
35
+ """
36
+
37
+ import asyncio
38
+ import json
39
+ import sys
40
+ import os
41
+ from datetime import datetime
42
+ from typing import Dict, List, Any
43
+
44
+ # Add the app directory to Python path
45
+ sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
46
+
47
+ from motor.motor_asyncio import AsyncIOMotorClient
48
+ from app.taxonomy.services.service import TaxonomyService
49
+ from app.taxonomy.models.model import TaxonomyModel
50
+ from app.taxonomy.schemas.schema import TaxonomyInfo, TaxonomyListRequest, UOMConversionGroup, UOMConversionDetail
51
+ from app.constants.collections import SCM_TAXONOMY_COLLECTION
52
+ from app.core.config import settings as app_settings
53
+
54
+
55
+ class TaxonomyTestRunner:
56
+ """Complete taxonomy implementation test runner."""
57
+
58
+ def __init__(self):
59
+ self.client = None
60
+ self.db = None
61
+ self.service = None
62
+ self.test_merchant_id = "company_cuatro_beauty_ltd"
63
+
64
+ async def setup(self):
65
+ """Setup test environment."""
66
+ print("πŸ”§ Setting up test environment...")
67
+
68
+ # Create database connection
69
+ self.client = AsyncIOMotorClient(app_settings.MONGODB_URI)
70
+ self.db = self.client["scm_test_complete_taxonomy"]
71
+
72
+ # Create service instance
73
+ self.service = TaxonomyService.__new__(TaxonomyService)
74
+ self.service.db = self.db
75
+ self.service.model = TaxonomyModel.__new__(TaxonomyModel)
76
+ self.service.model.db = self.db
77
+ self.service.model.collection = self.db[SCM_TAXONOMY_COLLECTION]
78
+
79
+ # Clear any existing test data
80
+ await self.db[SCM_TAXONOMY_COLLECTION].delete_many({"merchant_id": self.test_merchant_id})
81
+
82
+ print("βœ… Test environment setup complete")
83
+
84
+ async def cleanup(self):
85
+ """Cleanup test environment."""
86
+ print("🧹 Cleaning up test environment...")
87
+ try:
88
+ await self.db[SCM_TAXONOMY_COLLECTION].delete_many({"merchant_id": self.test_merchant_id})
89
+ self.client.close()
90
+ print("βœ… Cleanup complete")
91
+ except Exception as e:
92
+ print(f"⚠️ Cleanup warning: {e}")
93
+
94
+ def create_complete_taxonomy_data(self) -> TaxonomyInfo:
95
+ """Create complete taxonomy data based on the provided JSON structure."""
96
+
97
+ # UOM conversions from the JSON
98
+ uom_conversions = [
99
+ UOMConversionGroup(
100
+ base_uom="g",
101
+ conversions=[
102
+ UOMConversionDetail(
103
+ alt_uom="kg",
104
+ factor=1000.0,
105
+ description="1 kg = 1000 g"
106
+ )
107
+ ]
108
+ ),
109
+ UOMConversionGroup(
110
+ base_uom="ml",
111
+ conversions=[
112
+ UOMConversionDetail(
113
+ alt_uom="L",
114
+ factor=1000.0,
115
+ description="1 L = 1000 ml"
116
+ ),
117
+ UOMConversionDetail(
118
+ alt_uom="bottle",
119
+ factor=650.0,
120
+ description="650 ml bottle"
121
+ )
122
+ ]
123
+ ),
124
+ UOMConversionGroup(
125
+ base_uom="pcs",
126
+ conversions=[
127
+ UOMConversionDetail(
128
+ alt_uom="dozen",
129
+ factor=12.0,
130
+ description="1 dozen = 12 pcs"
131
+ ),
132
+ UOMConversionDetail(
133
+ alt_uom="pack",
134
+ factor=6.0,
135
+ description="1 pack = 6 pcs (example)"
136
+ )
137
+ ]
138
+ ),
139
+ UOMConversionGroup(
140
+ base_uom="hr",
141
+ conversions=[]
142
+ ),
143
+ UOMConversionGroup(
144
+ base_uom="L",
145
+ conversions=[
146
+ UOMConversionDetail(
147
+ alt_uom="bottle",
148
+ factor=1.0,
149
+ description="1 bottle = 1 L (example SKU)"
150
+ )
151
+ ]
152
+ )
153
+ ]
154
+
155
+ # Subcategories from the JSON
156
+ subcategories = {
157
+ "Hair": ["Hair Cut", "Hair Color", "Hair Spa", "Keratin Treatment", "Hair Smoothening"],
158
+ "Skin": ["Clean-up", "Facial", "De-Tan", "Peel Treatments", "Skin Brightening"],
159
+ "Nails": ["Manicure", "Pedicure", "Nail Art", "Gel Polish"],
160
+ "Spa": ["Head Massage", "Full Body Massage", "Aromatherapy", "Deep Tissue Massage"],
161
+ "Makeup": ["Bridal Makeup", "Party Makeup"],
162
+ "Foot": ["Pedicure"],
163
+ "H": ["Serum"]
164
+ }
165
+
166
+ return TaxonomyInfo(
167
+ merchant_id=self.test_merchant_id,
168
+
169
+ # Product Taxonomies
170
+ brands=[
171
+ "L'OrΓ©al Professional", "Schwarzkopf", "Kerastase", "Olaplex", "Lotus",
172
+ "b1", "g", " Services", "Brand1", "Brand2", "Brand3", "Brand4",
173
+ "Glory", "NewBrand", "vb", ""
174
+ ],
175
+ categories=[
176
+ "Hair", "Skin", "Nails", "Spa", "Makeup", "Foot", "ea", "saddle",
177
+ "d", "Glory", "Dummy123", "Alpha1", "Category1", "Hello",
178
+ "test-category", "Child Hair Stylist", "Hair color", "hair sap"
179
+ ],
180
+ lines=[
181
+ "Hair Care", "Hair Styling", "Facials", "Body Treatments",
182
+ "Manicure", "Pedicure", "Ayurveda", "GLoryt"
183
+ ],
184
+ classes=["Premium", "Luxury", "Basic", "Class1", "Class2", "VIP"],
185
+ subcategories=subcategories,
186
+
187
+ # Employee Taxonomies
188
+ job_role=["Senior Stylist", "Director", "Massage therapist", "Esthetician"],
189
+ specializations=["Hairdresser", "Nail technician", "Massage therapist", "Esthetician", "hair style"],
190
+ languages=["English", "Hindi", "Tamil"],
191
+
192
+ # Customer Taxonomies
193
+ customer_group=["VIP", "Regular", "Premium", "Enterprise", "Gold"],
194
+
195
+ # Operational Taxonomies - POS & Payment
196
+ pos_tender_modes=["Cash", "Credit Card", "Debit Card", "Mobile Payment"],
197
+ payment_types=["Cash and Carry", "Credit"],
198
+ payment_methods=["Bank Transfer", "Cash", "Cheque"],
199
+
200
+ # Operational Taxonomies - Asset & Inventory
201
+ asset_location=["Store Floor", "Warehouse", "Back Office"],
202
+ asset_category=["Electronics", "Furniture", "Equipment"],
203
+ stock_bin_location=["A1-01", "B2-15", "C3-07", "Receiving"],
204
+
205
+ # Operational Taxonomies - Business Structure
206
+ branch_types=["Flagship", "Outlet", "Pop-up", "Franchise"],
207
+ expense_types=["Travel", "Office Supplies"],
208
+
209
+ # UOM Conversions
210
+ uom_conversions=uom_conversions,
211
+
212
+ # Metadata
213
+ created_by="AST011",
214
+ created_at=datetime(2025, 8, 19, 9, 50, 11, 927000),
215
+ updated_at=datetime(2025, 12, 12, 6, 31, 8, 509000)
216
+ )
217
+
218
+ async def test_create_complete_taxonomy(self):
219
+ """Test 1: Create complete taxonomy data."""
220
+ print("\nπŸ“ Test 1: Creating complete taxonomy data...")
221
+
222
+ taxonomy_data = self.create_complete_taxonomy_data()
223
+
224
+ result = await self.service.create_or_update_taxonomy(
225
+ data=taxonomy_data,
226
+ created_by="AST011"
227
+ )
228
+
229
+ assert result["success"] is True
230
+ assert result["operation"] == "create"
231
+ assert "taxonomy_id" in result
232
+
233
+ print(f"βœ… Created taxonomy with ID: {result['taxonomy_id']}")
234
+ print(f" Operation: {result['operation']}")
235
+ return result["taxonomy_id"]
236
+
237
+ async def test_list_complete_taxonomy(self, taxonomy_id: str):
238
+ """Test 2: List complete taxonomy data without projection."""
239
+ print("\nπŸ“‹ Test 2: Listing complete taxonomy data...")
240
+
241
+ request = TaxonomyListRequest(merchant_id=self.test_merchant_id)
242
+ result = await self.service.list_taxonomy(request)
243
+
244
+ assert result["success"] is True
245
+ assert result["projection_applied"] is False
246
+ assert result["merchant_id"] == self.test_merchant_id
247
+
248
+ data = result["data"]
249
+ print(f"βœ… Retrieved complete taxonomy data")
250
+ print(f" Data type: {type(data)}")
251
+ print(f" Has taxonomies: {'taxonomies' in data if isinstance(data, dict) else 'N/A'}")
252
+
253
+ if isinstance(data, dict) and "taxonomies" in data:
254
+ taxonomies = data["taxonomies"]
255
+ print(f" Available taxonomy types: {list(taxonomies.keys())}")
256
+ print(f" Brands count: {len(taxonomies.get('brands', []))}")
257
+ print(f" Categories count: {len(taxonomies.get('categories', []))}")
258
+ print(f" UOM conversions count: {len(taxonomies.get('uom_conversions', []))}")
259
+
260
+ return data
261
+
262
+ async def test_projection_list_performance(self):
263
+ """Test 3: Test projection list for performance optimization."""
264
+ print("\nπŸš€ Test 3: Testing projection list performance...")
265
+
266
+ # Test with specific fields projection
267
+ projection_fields = ["merchant_id", "brands", "categories", "job_role", "created_at"]
268
+ request = TaxonomyListRequest(
269
+ merchant_id=self.test_merchant_id,
270
+ projection_list=projection_fields
271
+ )
272
+
273
+ result = await self.service.list_taxonomy(request)
274
+
275
+ assert result["success"] is True
276
+ assert result["projection_applied"] is True
277
+
278
+ data = result["data"]
279
+ print(f"βœ… Projection applied successfully")
280
+ print(f" Requested fields: {projection_fields}")
281
+ print(f" Data type: {type(data)}")
282
+
283
+ if isinstance(data, list) and data:
284
+ doc = data[0]
285
+ print(f" Returned fields: {list(doc.keys())}")
286
+
287
+ # Verify only requested fields are present (plus any defaults)
288
+ for field in projection_fields:
289
+ if field in ["brands", "categories", "job_role"] and doc.get(field):
290
+ print(f" βœ“ {field}: {len(doc[field])} items")
291
+
292
+ return data
293
+
294
+ async def test_taxonomy_type_filtering(self):
295
+ """Test 4: Test filtering by specific taxonomy type."""
296
+ print("\nπŸ” Test 4: Testing taxonomy type filtering...")
297
+
298
+ # Test filtering by brands
299
+ request = TaxonomyListRequest(
300
+ merchant_id=self.test_merchant_id,
301
+ taxonomy_type="brands"
302
+ )
303
+
304
+ result = await self.service.list_taxonomy(request)
305
+
306
+ assert result["success"] is True
307
+ assert result["taxonomy_type"] == "brands"
308
+
309
+ data = result["data"]
310
+ print(f"βœ… Filtered by taxonomy type: brands")
311
+ print(f" Data type: {type(data)}")
312
+
313
+ if isinstance(data, list) and data:
314
+ doc = data[0]
315
+ print(f" Document keys: {list(doc.keys())}")
316
+ if "data" in doc:
317
+ print(f" Brands count: {len(doc['data'])}")
318
+ print(f" Sample brands: {doc['data'][:3]}")
319
+
320
+ # Test filtering by UOM conversions
321
+ request_uom = TaxonomyListRequest(
322
+ merchant_id=self.test_merchant_id,
323
+ taxonomy_type="uom_conversions"
324
+ )
325
+
326
+ result_uom = await self.service.list_taxonomy(request_uom)
327
+ assert result_uom["success"] is True
328
+
329
+ print(f"βœ… Filtered by taxonomy type: uom_conversions")
330
+
331
+ return data
332
+
333
+ async def test_projection_with_taxonomy_type(self):
334
+ """Test 5: Test projection combined with taxonomy type filtering."""
335
+ print("\n🎯 Test 5: Testing projection + taxonomy type filtering...")
336
+
337
+ request = TaxonomyListRequest(
338
+ merchant_id=self.test_merchant_id,
339
+ taxonomy_type="categories",
340
+ projection_list=["merchant_id", "categories", "created_at"]
341
+ )
342
+
343
+ result = await self.service.list_taxonomy(request)
344
+
345
+ assert result["success"] is True
346
+ assert result["projection_applied"] is True
347
+ assert result["taxonomy_type"] == "categories"
348
+
349
+ data = result["data"]
350
+ print(f"βœ… Combined projection + filtering applied")
351
+ print(f" Taxonomy type: categories")
352
+ print(f" Projection fields: merchant_id, categories, created_at")
353
+ print(f" Data type: {type(data)}")
354
+
355
+ if isinstance(data, list) and data:
356
+ doc = data[0]
357
+ print(f" Returned fields: {list(doc.keys())}")
358
+
359
+ return data
360
+
361
+ async def test_uom_conversions_management(self):
362
+ """Test 6: Test UOM conversions CRUD operations."""
363
+ print("\nβš–οΈ Test 6: Testing UOM conversions management...")
364
+
365
+ # Test getting UOM conversions
366
+ uom_result = await self.service.get_uom_conversions(
367
+ merchant_id=self.test_merchant_id
368
+ )
369
+
370
+ assert uom_result["success"] is True
371
+
372
+ uom_data = uom_result["data"]
373
+ print(f"βœ… Retrieved UOM conversions")
374
+ print(f" Conversion groups: {len(uom_data)}")
375
+ print(f" Is default: {uom_result.get('is_default', False)}")
376
+
377
+ # Print sample conversions
378
+ for i, group in enumerate(uom_data[:2]): # Show first 2 groups
379
+ if isinstance(group, dict):
380
+ base_uom = group.get("base_uom")
381
+ conversions = group.get("conversions", [])
382
+ print(f" Group {i+1}: {base_uom} -> {len(conversions)} conversions")
383
+ if conversions:
384
+ sample = conversions[0]
385
+ print(f" Sample: {sample.get('alt_uom')} (factor: {sample.get('factor')})")
386
+
387
+ return uom_data
388
+
389
+ async def test_update_taxonomy_data(self):
390
+ """Test 7: Test updating taxonomy data."""
391
+ print("\n✏️ Test 7: Testing taxonomy data updates...")
392
+
393
+ # Add new brands
394
+ update_data = TaxonomyInfo(
395
+ merchant_id=self.test_merchant_id,
396
+ brands=["New Brand 1", "New Brand 2", "Updated Brand"],
397
+ categories=["New Category"],
398
+ expense_types=["Marketing", "Training"],
399
+ created_by="AST014"
400
+ )
401
+
402
+ result = await self.service.create_or_update_taxonomy(
403
+ data=update_data,
404
+ created_by="AST014"
405
+ )
406
+
407
+ assert result["success"] is True
408
+ assert result["operation"] == "update"
409
+
410
+ print(f"βœ… Updated taxonomy data")
411
+ print(f" Operation: {result['operation']}")
412
+ print(f" Modified count: {result.get('modified_count', 0)}")
413
+
414
+ # Verify the update
415
+ request = TaxonomyListRequest(
416
+ merchant_id=self.test_merchant_id,
417
+ projection_list=["brands", "categories", "expense_types"]
418
+ )
419
+
420
+ verify_result = await self.service.list_taxonomy(request)
421
+ data = verify_result["data"]
422
+
423
+ if isinstance(data, list) and data:
424
+ doc = data[0]
425
+ brands = doc.get("brands", [])
426
+ expense_types = doc.get("expense_types", [])
427
+
428
+ print(f" Updated brands count: {len(brands)}")
429
+ print(f" Updated expense_types count: {len(expense_types)}")
430
+
431
+ # Check if new items were added
432
+ new_brands = ["New Brand 1", "New Brand 2", "Updated Brand"]
433
+ new_expenses = ["Marketing", "Training"]
434
+
435
+ for brand in new_brands:
436
+ if brand in brands:
437
+ print(f" βœ“ Added brand: {brand}")
438
+
439
+ for expense in new_expenses:
440
+ if expense in expense_types:
441
+ print(f" βœ“ Added expense type: {expense}")
442
+
443
+ return result
444
+
445
+ async def test_delete_taxonomy_values(self):
446
+ """Test 8: Test deleting specific taxonomy values."""
447
+ print("\nπŸ—‘οΈ Test 8: Testing taxonomy value deletion...")
448
+
449
+ # Delete specific brands using the delete flag
450
+ delete_data = TaxonomyInfo(
451
+ merchant_id=self.test_merchant_id,
452
+ brands=["New Brand 1", "Updated Brand"], # These will be deleted
453
+ is_delete=True,
454
+ created_by="AST014"
455
+ )
456
+
457
+ result = await self.service.create_or_update_taxonomy(
458
+ data=delete_data,
459
+ created_by="AST014"
460
+ )
461
+
462
+ assert result["success"] is True
463
+ assert result["operation"] == "delete"
464
+
465
+ print(f"βœ… Deleted taxonomy values")
466
+ print(f" Operation: {result['operation']}")
467
+ print(f" Modified count: {result.get('modified_count', 0)}")
468
+
469
+ # Verify the deletion
470
+ request = TaxonomyListRequest(
471
+ merchant_id=self.test_merchant_id,
472
+ projection_list=["brands"]
473
+ )
474
+
475
+ verify_result = await self.service.list_taxonomy(request)
476
+ data = verify_result["data"]
477
+
478
+ if isinstance(data, list) and data:
479
+ doc = data[0]
480
+ brands = doc.get("brands", [])
481
+
482
+ print(f" Remaining brands count: {len(brands)}")
483
+
484
+ # Check if items were deleted
485
+ deleted_brands = ["New Brand 1", "Updated Brand"]
486
+ for brand in deleted_brands:
487
+ if brand not in brands:
488
+ print(f" βœ“ Deleted brand: {brand}")
489
+ else:
490
+ print(f" ⚠️ Brand still exists: {brand}")
491
+
492
+ return result
493
+
494
+ async def test_performance_comparison(self):
495
+ """Test 9: Compare performance with and without projection."""
496
+ print("\n⚑ Test 9: Performance comparison...")
497
+
498
+ import time
499
+
500
+ # Test without projection (full data)
501
+ start_time = time.time()
502
+ request_full = TaxonomyListRequest(merchant_id=self.test_merchant_id)
503
+ result_full = await self.service.list_taxonomy(request_full)
504
+ full_time = time.time() - start_time
505
+
506
+ # Test with projection (limited fields)
507
+ start_time = time.time()
508
+ request_projected = TaxonomyListRequest(
509
+ merchant_id=self.test_merchant_id,
510
+ projection_list=["merchant_id", "brands", "categories"]
511
+ )
512
+ result_projected = await self.service.list_taxonomy(request_projected)
513
+ projected_time = time.time() - start_time
514
+
515
+ print(f"βœ… Performance comparison completed")
516
+ print(f" Full data query time: {full_time:.4f}s")
517
+ print(f" Projected query time: {projected_time:.4f}s")
518
+
519
+ # Calculate data size difference (rough estimate)
520
+ full_data = result_full["data"]
521
+ projected_data = result_projected["data"]
522
+
523
+ if isinstance(full_data, dict) and isinstance(projected_data, list):
524
+ full_str = json.dumps(full_data, default=str)
525
+ projected_str = json.dumps(projected_data, default=str)
526
+
527
+ full_size = len(full_str)
528
+ projected_size = len(projected_str)
529
+
530
+ size_reduction = ((full_size - projected_size) / full_size) * 100 if full_size > 0 else 0
531
+
532
+ print(f" Full data size: {full_size} chars")
533
+ print(f" Projected data size: {projected_size} chars")
534
+ print(f" Size reduction: {size_reduction:.1f}%")
535
+
536
+ return {
537
+ "full_time": full_time,
538
+ "projected_time": projected_time,
539
+ "full_data": full_data,
540
+ "projected_data": projected_data
541
+ }
542
+
543
+ async def run_all_tests(self):
544
+ """Run all taxonomy tests."""
545
+ print("πŸš€ Starting Complete Taxonomy Implementation Tests")
546
+ print("=" * 60)
547
+
548
+ try:
549
+ await self.setup()
550
+
551
+ # Run all tests in sequence
552
+ taxonomy_id = await self.test_create_complete_taxonomy()
553
+ await self.test_list_complete_taxonomy(taxonomy_id)
554
+ await self.test_projection_list_performance()
555
+ await self.test_taxonomy_type_filtering()
556
+ await self.test_projection_with_taxonomy_type()
557
+ await self.test_uom_conversions_management()
558
+ await self.test_update_taxonomy_data()
559
+ await self.test_delete_taxonomy_values()
560
+ await self.test_performance_comparison()
561
+
562
+ print("\n" + "=" * 60)
563
+ print("πŸŽ‰ All tests completed successfully!")
564
+ print("\nπŸ“Š Test Summary:")
565
+ print("βœ… Complete taxonomy creation")
566
+ print("βœ… Full data retrieval")
567
+ print("βœ… Projection list optimization")
568
+ print("βœ… Taxonomy type filtering")
569
+ print("βœ… Combined projection + filtering")
570
+ print("βœ… UOM conversions management")
571
+ print("βœ… Data updates")
572
+ print("βœ… Value deletion")
573
+ print("βœ… Performance comparison")
574
+
575
+ except Exception as e:
576
+ print(f"\n❌ Test failed with error: {e}")
577
+ import traceback
578
+ traceback.print_exc()
579
+
580
+ finally:
581
+ await self.cleanup()
582
+
583
+
584
+ async def main():
585
+ """Main test runner."""
586
+ runner = TaxonomyTestRunner()
587
+ await runner.run_all_tests()
588
+
589
+
590
+ if __name__ == "__main__":
591
+ asyncio.run(main())