Files
bakery-ia/services/suppliers/app/api/suppliers.py

723 lines
26 KiB
Python
Raw Normal View History

# services/suppliers/app/api/suppliers.py
"""
2025-10-06 15:27:01 +02:00
Supplier CRUD API endpoints (ATOMIC)
"""
2025-10-06 15:27:01 +02:00
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
2025-12-18 13:26:32 +01:00
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
2025-10-21 19:50:07 +02:00
from sqlalchemy import select
from app.core.database import get_db
from app.services.supplier_service import SupplierService
2025-10-21 19:50:07 +02:00
from app.models.suppliers import SupplierPriceList
2025-10-29 06:58:05 +01:00
from app.models import AuditLog
from app.schemas.suppliers import (
SupplierCreate, SupplierUpdate, SupplierResponse, SupplierSummary,
2025-10-29 06:58:05 +01:00
SupplierSearchParams, SupplierDeletionSummary,
SupplierPriceListCreate, SupplierPriceListUpdate, SupplierPriceListResponse
)
2025-08-15 22:40:19 +02:00
from shared.auth.decorators import get_current_user_dep
2025-10-06 15:27:01 +02:00
from shared.routing import RouteBuilder
from shared.auth.access_control import require_user_role
from shared.security import create_audit_logger, AuditSeverity, AuditAction
2025-10-06 15:27:01 +02:00
# Create route builder for consistent URL structure
route_builder = RouteBuilder('suppliers')
router = APIRouter(tags=["suppliers"])
logger = structlog.get_logger()
2025-10-29 06:58:05 +01:00
audit_logger = create_audit_logger("suppliers-service", AuditLog)
2025-10-27 16:33:26 +01:00
@router.post(route_builder.build_base_route(""), response_model=SupplierResponse)
2025-10-06 15:27:01 +02:00
@require_user_role(['admin', 'owner', 'member'])
async def create_supplier(
supplier_data: SupplierCreate,
tenant_id: str = Path(..., description="Tenant ID"),
2025-08-15 22:40:19 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
2025-10-06 15:27:01 +02:00
"""Create a new supplier"""
try:
2025-12-18 13:26:32 +01:00
# CRITICAL: Check subscription limit before creating
from app.core.config import settings
async with httpx.AsyncClient(timeout=5.0) as client:
try:
limit_check_response = await client.get(
f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/{tenant_id}/suppliers/can-add",
headers={
"x-user-id": str(current_user.get('user_id')),
"x-tenant-id": str(tenant_id)
}
)
if limit_check_response.status_code == 200:
limit_check = limit_check_response.json()
if not limit_check.get('can_add', False):
logger.warning(
"Supplier limit exceeded",
tenant_id=tenant_id,
current=limit_check.get('current_count'),
max=limit_check.get('max_allowed'),
reason=limit_check.get('reason')
)
raise HTTPException(
status_code=402,
detail={
"error": "supplier_limit_exceeded",
"message": limit_check.get('reason', 'Supplier limit exceeded'),
"current_count": limit_check.get('current_count'),
"max_allowed": limit_check.get('max_allowed'),
"upgrade_required": True
}
)
else:
logger.warning(
"Failed to check supplier limit, allowing creation",
tenant_id=tenant_id,
status_code=limit_check_response.status_code
)
except httpx.TimeoutException:
logger.warning("Timeout checking supplier limit, allowing creation", tenant_id=tenant_id)
except httpx.RequestError as e:
logger.warning("Error checking supplier limit, allowing creation", tenant_id=tenant_id, error=str(e))
service = SupplierService(db)
2025-10-27 16:33:26 +01:00
# Get user role from current_user dict
user_role = current_user.get("role", "member").lower()
supplier = await service.create_supplier(
tenant_id=UUID(tenant_id),
supplier_data=supplier_data,
2025-10-27 16:33:26 +01:00
created_by=current_user["user_id"],
created_by_role=user_role
)
2025-12-18 13:26:32 +01:00
logger.info("Supplier created successfully", tenant_id=tenant_id, supplier_id=str(supplier.id), supplier_name=supplier.name)
return SupplierResponse.from_orm(supplier)
2025-12-18 13:26:32 +01:00
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating supplier", error=str(e))
raise HTTPException(status_code=500, detail="Failed to create supplier")
2025-10-27 16:33:26 +01:00
@router.get(route_builder.build_base_route(""), response_model=List[SupplierSummary])
async def list_suppliers(
tenant_id: str = Path(..., description="Tenant ID"),
search_term: Optional[str] = Query(None, description="Search term"),
supplier_type: Optional[str] = Query(None, description="Supplier type filter"),
status: Optional[str] = Query(None, description="Status filter"),
limit: int = Query(50, ge=1, le=1000, description="Number of results to return"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
db: AsyncSession = Depends(get_db)
):
"""List suppliers with optional filters"""
try:
service = SupplierService(db)
search_params = SupplierSearchParams(
search_term=search_term,
supplier_type=supplier_type,
status=status,
limit=limit,
offset=offset
)
suppliers = await service.search_suppliers(
tenant_id=UUID(tenant_id),
search_params=search_params
)
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
except Exception as e:
logger.error("Error listing suppliers", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers")
2025-12-05 20:07:01 +01:00
@router.get(route_builder.build_base_route("batch"), response_model=List[SupplierSummary])
async def get_suppliers_batch(
tenant_id: str = Path(..., description="Tenant ID"),
ids: str = Query(..., description="Comma-separated supplier IDs"),
db: AsyncSession = Depends(get_db)
):
"""
Get multiple suppliers in a single call for performance optimization.
This endpoint is designed to eliminate N+1 query patterns when fetching
supplier data for multiple purchase orders or other entities.
Args:
tenant_id: Tenant ID
ids: Comma-separated supplier IDs (e.g., "abc123,def456,xyz789")
Returns:
List of supplier summaries for the requested IDs
"""
try:
service = SupplierService(db)
# Parse comma-separated IDs
supplier_ids = [id.strip() for id in ids.split(",") if id.strip()]
if not supplier_ids:
return []
if len(supplier_ids) > 100:
raise HTTPException(
status_code=400,
detail="Maximum 100 supplier IDs allowed per batch request"
)
# Convert to UUIDs
try:
uuid_ids = [UUID(id) for id in supplier_ids]
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid supplier ID format: {e}")
# Fetch suppliers
suppliers = await service.get_suppliers_batch(tenant_id=UUID(tenant_id), supplier_ids=uuid_ids)
logger.info(
"Batch retrieved suppliers",
tenant_id=tenant_id,
requested_count=len(supplier_ids),
found_count=len(suppliers)
)
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
except HTTPException:
raise
except Exception as e:
logger.error("Error batch retrieving suppliers", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers")
2025-10-27 16:33:26 +01:00
@router.get(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
async def get_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)
):
"""Get supplier by ID"""
try:
service = SupplierService(db)
supplier = await service.get_supplier(supplier_id)
2025-10-06 15:27:01 +02:00
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
2025-10-06 15:27:01 +02:00
return SupplierResponse.from_orm(supplier)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve supplier")
2025-10-27 16:33:26 +01:00
@router.put(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
2025-10-06 15:27:01 +02:00
@require_user_role(['admin', 'owner', 'member'])
async def update_supplier(
supplier_data: SupplierUpdate,
supplier_id: UUID = Path(..., description="Supplier ID"),
2025-08-15 22:40:19 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Update supplier information"""
try:
service = SupplierService(db)
2025-10-06 15:27:01 +02:00
# Check supplier exists
existing_supplier = await service.get_supplier(supplier_id)
if not existing_supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
2025-10-06 15:27:01 +02:00
supplier = await service.update_supplier(
supplier_id=supplier_id,
supplier_data=supplier_data,
2025-10-27 16:33:26 +01:00
updated_by=current_user["user_id"]
)
2025-10-06 15:27:01 +02:00
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
2025-10-06 15:27:01 +02:00
return SupplierResponse.from_orm(supplier)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error updating supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to update supplier")
2025-10-27 16:33:26 +01:00
@router.delete(route_builder.build_resource_detail_route("", "supplier_id"))
2025-10-06 15:27:01 +02:00
@require_user_role(['admin', 'owner'])
async def delete_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Delete supplier (soft delete, Admin+ only)"""
try:
service = SupplierService(db)
2025-10-06 15:27:01 +02:00
# Check supplier exists
existing_supplier = await service.get_supplier(supplier_id)
if not existing_supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
2025-10-06 15:27:01 +02:00
# Capture supplier data before deletion
supplier_data = {
"supplier_name": existing_supplier.name,
"supplier_type": existing_supplier.supplier_type,
"contact_person": existing_supplier.contact_person,
"email": existing_supplier.email
}
success = await service.delete_supplier(supplier_id)
if not success:
raise HTTPException(status_code=404, detail="Supplier not found")
2025-10-06 15:27:01 +02:00
# Log audit event for supplier deletion
try:
# Get sync db session for audit logging
from app.core.database import SessionLocal
sync_db = SessionLocal()
try:
await audit_logger.log_deletion(
db_session=sync_db,
tenant_id=tenant_id,
user_id=current_user["user_id"],
resource_type="supplier",
resource_id=str(supplier_id),
resource_data=supplier_data,
description=f"Admin {current_user.get('email', 'unknown')} deleted supplier",
endpoint=f"/suppliers/{supplier_id}",
method="DELETE"
)
sync_db.commit()
finally:
sync_db.close()
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Deleted supplier",
supplier_id=str(supplier_id),
tenant_id=tenant_id,
user_id=current_user["user_id"])
return {"message": "Supplier deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error("Error deleting supplier", supplier_id=str(supplier_id), error=str(e))
2025-10-21 19:50:07 +02:00
raise HTTPException(status_code=500, detail="Failed to delete supplier")
2025-10-27 16:33:26 +01:00
@router.delete(
route_builder.build_resource_action_route("", "supplier_id", "hard"),
response_model=SupplierDeletionSummary
)
@require_user_role(['admin', 'owner'])
async def hard_delete_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Hard delete supplier and all associated data (Admin/Owner only, permanent)"""
try:
service = SupplierService(db)
# Check supplier exists
existing_supplier = await service.get_supplier(supplier_id)
if not existing_supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
# Capture supplier data before deletion
supplier_data = {
"id": str(existing_supplier.id),
"name": existing_supplier.name,
"status": existing_supplier.status.value,
"supplier_code": existing_supplier.supplier_code
}
# Perform hard deletion
deletion_summary = await service.hard_delete_supplier(supplier_id, UUID(tenant_id))
# Log audit event for hard deletion
try:
# Get sync db session for audit logging
from app.core.database import SessionLocal
sync_db = SessionLocal()
try:
await audit_logger.log_deletion(
db_session=sync_db,
tenant_id=tenant_id,
user_id=current_user["user_id"],
resource_type="supplier",
resource_id=str(supplier_id),
resource_data=supplier_data,
description=f"Hard deleted supplier '{supplier_data['name']}' and all associated data",
endpoint=f"/suppliers/{supplier_id}/hard",
method="DELETE",
metadata=deletion_summary
)
sync_db.commit()
finally:
sync_db.close()
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Hard deleted supplier",
supplier_id=str(supplier_id),
tenant_id=tenant_id,
user_id=current_user["user_id"],
deletion_summary=deletion_summary)
return deletion_summary
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error hard deleting supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to hard delete supplier")
@router.get(
2025-10-24 13:05:04 +02:00
route_builder.build_base_route("count"),
response_model=dict
)
async def count_suppliers(
tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)
):
"""Get count of suppliers for a tenant"""
try:
service = SupplierService(db)
2025-10-24 13:05:04 +02:00
# Use search with maximum allowed limit to get all suppliers
search_params = SupplierSearchParams(limit=1000)
suppliers = await service.search_suppliers(
tenant_id=UUID(tenant_id),
search_params=search_params
)
count = len(suppliers)
logger.info("Retrieved supplier count", tenant_id=tenant_id, count=count)
return {"count": count}
except Exception as e:
logger.error("Error counting suppliers", tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail="Failed to count suppliers")
2025-10-21 19:50:07 +02:00
@router.get(
2025-10-27 16:33:26 +01:00
route_builder.build_resource_action_route("", "supplier_id", "products"),
2025-10-21 19:50:07 +02:00
response_model=List[Dict[str, Any]]
)
async def get_supplier_products(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
is_active: bool = Query(True, description="Filter by active price lists"),
db: AsyncSession = Depends(get_db)
):
"""
Get list of product IDs that a supplier provides
Returns a list of inventory product IDs from the supplier's price list
"""
try:
# Query supplier price lists
query = select(SupplierPriceList).where(
SupplierPriceList.tenant_id == UUID(tenant_id),
SupplierPriceList.supplier_id == supplier_id
)
if is_active:
query = query.where(SupplierPriceList.is_active == True)
result = await db.execute(query)
price_lists = result.scalars().all()
# Extract unique product IDs
product_ids = list(set([str(pl.inventory_product_id) for pl in price_lists]))
logger.info(
"Retrieved supplier products",
supplier_id=str(supplier_id),
product_count=len(product_ids)
)
return [{"inventory_product_id": pid} for pid in product_ids]
except Exception as e:
logger.error(
"Error getting supplier products",
supplier_id=str(supplier_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to retrieve supplier products"
2025-10-29 06:58:05 +01:00
)
@router.get(
route_builder.build_resource_action_route("", "supplier_id", "price-lists"),
response_model=List[SupplierPriceListResponse]
)
async def get_supplier_price_lists(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
is_active: bool = Query(True, description="Filter by active price lists"),
db: AsyncSession = Depends(get_db)
):
"""Get all price list items for a supplier"""
try:
service = SupplierService(db)
price_lists = await service.get_supplier_price_lists(
supplier_id=supplier_id,
tenant_id=UUID(tenant_id),
is_active=is_active
)
logger.info(
"Retrieved supplier price lists",
supplier_id=str(supplier_id),
count=len(price_lists)
)
return [SupplierPriceListResponse.from_orm(pl) for pl in price_lists]
except Exception as e:
logger.error(
"Error getting supplier price lists",
supplier_id=str(supplier_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to retrieve supplier price lists"
)
@router.get(
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"),
response_model=SupplierPriceListResponse
)
async def get_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_id: UUID = Path(..., description="Price List ID"),
tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)
):
"""Get specific price list item for a supplier"""
try:
service = SupplierService(db)
price_list = await service.get_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not price_list:
raise HTTPException(status_code=404, detail="Price list item not found")
logger.info(
"Retrieved supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id)
)
return SupplierPriceListResponse.from_orm(price_list)
except HTTPException:
raise
except Exception as e:
logger.error(
"Error getting supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to retrieve supplier price list item"
)
@router.post(
route_builder.build_resource_action_route("", "supplier_id", "price-lists"),
response_model=SupplierPriceListResponse
)
@require_user_role(['admin', 'owner', 'member'])
async def create_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_data: SupplierPriceListCreate = None,
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Create a new price list item for a supplier"""
try:
service = SupplierService(db)
# Verify supplier exists
supplier = await service.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
price_list = await service.create_supplier_price_list(
supplier_id=supplier_id,
price_list_data=price_list_data,
tenant_id=UUID(tenant_id),
created_by=UUID(current_user["user_id"])
)
logger.info(
"Created supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list.id)
)
return SupplierPriceListResponse.from_orm(price_list)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Error creating supplier price list item",
supplier_id=str(supplier_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to create supplier price list item"
)
@router.put(
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"),
response_model=SupplierPriceListResponse
)
@require_user_role(['admin', 'owner', 'member'])
async def update_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_id: UUID = Path(..., description="Price List ID"),
price_list_data: SupplierPriceListUpdate = None,
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Update a price list item for a supplier"""
try:
service = SupplierService(db)
# Verify supplier and price list exist
supplier = await service.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
price_list = await service.get_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not price_list:
raise HTTPException(status_code=404, detail="Price list item not found")
updated_price_list = await service.update_supplier_price_list(
price_list_id=price_list_id,
price_list_data=price_list_data,
tenant_id=UUID(tenant_id),
updated_by=UUID(current_user["user_id"])
)
logger.info(
"Updated supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id)
)
return SupplierPriceListResponse.from_orm(updated_price_list)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Error updating supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to update supplier price list item"
)
@router.delete(
route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}")
)
@require_user_role(['admin', 'owner'])
async def delete_supplier_price_list(
supplier_id: UUID = Path(..., description="Supplier ID"),
price_list_id: UUID = Path(..., description="Price List ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Delete a price list item for a supplier"""
try:
service = SupplierService(db)
# Verify supplier and price list exist
supplier = await service.get_supplier(supplier_id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
price_list = await service.get_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not price_list:
raise HTTPException(status_code=404, detail="Price list item not found")
success = await service.delete_supplier_price_list(
price_list_id=price_list_id,
tenant_id=UUID(tenant_id)
)
if not success:
raise HTTPException(status_code=404, detail="Price list item not found")
logger.info(
"Deleted supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id)
)
return {"message": "Price list item deleted successfully"}
except Exception as e:
logger.error(
"Error deleting supplier price list item",
supplier_id=str(supplier_id),
price_list_id=str(price_list_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail="Failed to delete supplier price list item"
)