Spaces:
Runtime error
Runtime error
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 +0 -0
- app/dependencies/auth.py +8 -3
- app/system_users/schemas/schema.py +1 -1
- app/taxonomy/controllers/router.py +117 -0
- app/taxonomy/models/model.py +1 -1
- app/taxonomy/schemas/schema.py +28 -19
- app/taxonomy/services/service.py +7 -4
- test_complete_taxonomy_implementation.py +591 -0
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",
|
|
|
|
| 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",
|
|
|
|
| 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",
|
|
|
|
| 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())
|