613 lines
22 KiB
Python
613 lines
22 KiB
Python
# services/suppliers/app/api/suppliers.py
|
|
"""
|
|
Supplier CRUD API endpoints (ATOMIC)
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
|
from typing import List, Optional, Dict, Any
|
|
from uuid import UUID
|
|
import structlog
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from app.core.database import get_db
|
|
from app.services.supplier_service import SupplierService
|
|
from app.models.suppliers import SupplierPriceList
|
|
from app.models import AuditLog
|
|
from app.schemas.suppliers import (
|
|
SupplierCreate, SupplierUpdate, SupplierResponse, SupplierSummary,
|
|
SupplierSearchParams, SupplierDeletionSummary,
|
|
SupplierPriceListCreate, SupplierPriceListUpdate, SupplierPriceListResponse
|
|
)
|
|
from shared.auth.decorators import get_current_user_dep
|
|
from shared.routing import RouteBuilder
|
|
from shared.auth.access_control import require_user_role
|
|
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
|
|
|
# Create route builder for consistent URL structure
|
|
route_builder = RouteBuilder('suppliers')
|
|
|
|
|
|
router = APIRouter(tags=["suppliers"])
|
|
logger = structlog.get_logger()
|
|
audit_logger = create_audit_logger("suppliers-service", AuditLog)
|
|
|
|
@router.post(route_builder.build_base_route(""), response_model=SupplierResponse)
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
async def create_supplier(
|
|
supplier_data: SupplierCreate,
|
|
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 supplier"""
|
|
try:
|
|
service = SupplierService(db)
|
|
|
|
# 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,
|
|
created_by=current_user["user_id"],
|
|
created_by_role=user_role
|
|
)
|
|
return SupplierResponse.from_orm(supplier)
|
|
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")
|
|
|
|
|
|
@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")
|
|
|
|
|
|
@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)
|
|
|
|
if not supplier:
|
|
raise HTTPException(status_code=404, detail="Supplier not found")
|
|
|
|
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")
|
|
|
|
|
|
@router.put(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
async def update_supplier(
|
|
supplier_data: SupplierUpdate,
|
|
supplier_id: UUID = Path(..., description="Supplier ID"),
|
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update supplier information"""
|
|
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")
|
|
|
|
supplier = await service.update_supplier(
|
|
supplier_id=supplier_id,
|
|
supplier_data=supplier_data,
|
|
updated_by=current_user["user_id"]
|
|
)
|
|
|
|
if not supplier:
|
|
raise HTTPException(status_code=404, detail="Supplier not found")
|
|
|
|
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")
|
|
|
|
|
|
@router.delete(route_builder.build_resource_detail_route("", "supplier_id"))
|
|
@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)
|
|
|
|
# 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 = {
|
|
"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")
|
|
|
|
# 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))
|
|
raise HTTPException(status_code=500, detail="Failed to delete supplier")
|
|
|
|
|
|
@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(
|
|
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)
|
|
|
|
# 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")
|
|
|
|
|
|
@router.get(
|
|
route_builder.build_resource_action_route("", "supplier_id", "products"),
|
|
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"
|
|
)
|
|
|
|
|
|
@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"
|
|
)
|