Create new services: inventory, recipes, suppliers
This commit is contained in:
1
services/suppliers/app/__init__.py
Normal file
1
services/suppliers/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/__init__.py
|
||||
1
services/suppliers/app/api/__init__.py
Normal file
1
services/suppliers/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/api/__init__.py
|
||||
404
services/suppliers/app/api/deliveries.py
Normal file
404
services/suppliers/app/api/deliveries.py
Normal file
@@ -0,0 +1,404 @@
|
||||
# services/suppliers/app/api/deliveries.py
|
||||
"""
|
||||
Delivery API endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.services.delivery_service import DeliveryService
|
||||
from app.schemas.suppliers import (
|
||||
DeliveryCreate, DeliveryUpdate, DeliveryResponse, DeliverySummary,
|
||||
DeliverySearchParams, DeliveryStatusUpdate, DeliveryReceiptConfirmation,
|
||||
DeliveryPerformanceStats, DeliverySummaryStats
|
||||
)
|
||||
from app.models.suppliers import DeliveryStatus
|
||||
from shared.auth.dependencies import get_current_user, require_permissions
|
||||
from shared.auth.models import UserInfo
|
||||
|
||||
router = APIRouter(prefix="/deliveries", tags=["deliveries"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@router.post("/", response_model=DeliveryResponse)
|
||||
async def create_delivery(
|
||||
delivery_data: DeliveryCreate,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new delivery"""
|
||||
require_permissions(current_user, ["deliveries:create"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
delivery = await service.create_delivery(
|
||||
tenant_id=current_user.tenant_id,
|
||||
delivery_data=delivery_data,
|
||||
created_by=current_user.user_id
|
||||
)
|
||||
return DeliveryResponse.from_orm(delivery)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error creating delivery", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to create delivery")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DeliverySummary])
|
||||
async def list_deliveries(
|
||||
supplier_id: Optional[UUID] = Query(None, description="Filter by supplier ID"),
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)"),
|
||||
search_term: Optional[str] = Query(None, description="Search term"),
|
||||
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"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List deliveries with optional filters"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# Parse date filters
|
||||
date_from_parsed = None
|
||||
date_to_parsed = None
|
||||
if date_from:
|
||||
try:
|
||||
date_from_parsed = datetime.fromisoformat(date_from)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_from format")
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
date_to_parsed = datetime.fromisoformat(date_to)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_to format")
|
||||
|
||||
# Validate status
|
||||
status_enum = None
|
||||
if status:
|
||||
try:
|
||||
status_enum = DeliveryStatus(status.upper())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
service = DeliveryService(db)
|
||||
search_params = DeliverySearchParams(
|
||||
supplier_id=supplier_id,
|
||||
status=status_enum,
|
||||
date_from=date_from_parsed,
|
||||
date_to=date_to_parsed,
|
||||
search_term=search_term,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
deliveries = await service.search_deliveries(
|
||||
tenant_id=current_user.tenant_id,
|
||||
search_params=search_params
|
||||
)
|
||||
|
||||
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error listing deliveries", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve deliveries")
|
||||
|
||||
|
||||
@router.get("/today", response_model=List[DeliverySummary])
|
||||
async def get_todays_deliveries(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get deliveries scheduled for today"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
deliveries = await service.get_todays_deliveries(current_user.tenant_id)
|
||||
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
|
||||
except Exception as e:
|
||||
logger.error("Error getting today's deliveries", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve today's deliveries")
|
||||
|
||||
|
||||
@router.get("/overdue", response_model=List[DeliverySummary])
|
||||
async def get_overdue_deliveries(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get overdue deliveries"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
deliveries = await service.get_overdue_deliveries(current_user.tenant_id)
|
||||
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
|
||||
except Exception as e:
|
||||
logger.error("Error getting overdue deliveries", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve overdue deliveries")
|
||||
|
||||
|
||||
@router.get("/scheduled", response_model=List[DeliverySummary])
|
||||
async def get_scheduled_deliveries(
|
||||
date_from: Optional[str] = Query(None, description="From date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="To date (YYYY-MM-DD)"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get scheduled deliveries for a date range"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
date_from_parsed = None
|
||||
date_to_parsed = None
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
date_from_parsed = datetime.fromisoformat(date_from)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_from format")
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
date_to_parsed = datetime.fromisoformat(date_to)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_to format")
|
||||
|
||||
service = DeliveryService(db)
|
||||
deliveries = await service.get_scheduled_deliveries(
|
||||
tenant_id=current_user.tenant_id,
|
||||
date_from=date_from_parsed,
|
||||
date_to=date_to_parsed
|
||||
)
|
||||
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting scheduled deliveries", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve scheduled deliveries")
|
||||
|
||||
|
||||
@router.get("/performance-stats", response_model=DeliveryPerformanceStats)
|
||||
async def get_delivery_performance_stats(
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
supplier_id: Optional[UUID] = Query(None, description="Filter by supplier ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get delivery performance statistics"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
stats = await service.get_delivery_performance_stats(
|
||||
tenant_id=current_user.tenant_id,
|
||||
days_back=days_back,
|
||||
supplier_id=supplier_id
|
||||
)
|
||||
return DeliveryPerformanceStats(**stats)
|
||||
except Exception as e:
|
||||
logger.error("Error getting delivery performance stats", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve delivery performance statistics")
|
||||
|
||||
|
||||
@router.get("/summary-stats", response_model=DeliverySummaryStats)
|
||||
async def get_delivery_summary_stats(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get delivery summary statistics for dashboard"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
stats = await service.get_upcoming_deliveries_summary(current_user.tenant_id)
|
||||
return DeliverySummaryStats(**stats)
|
||||
except Exception as e:
|
||||
logger.error("Error getting delivery summary stats", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve delivery summary statistics")
|
||||
|
||||
|
||||
@router.get("/{delivery_id}", response_model=DeliveryResponse)
|
||||
async def get_delivery(
|
||||
delivery_id: UUID = Path(..., description="Delivery ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get delivery by ID with items"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
delivery = await service.get_delivery(delivery_id)
|
||||
|
||||
if not delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
|
||||
# Check tenant access
|
||||
if delivery.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return DeliveryResponse.from_orm(delivery)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting delivery", delivery_id=str(delivery_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve delivery")
|
||||
|
||||
|
||||
@router.put("/{delivery_id}", response_model=DeliveryResponse)
|
||||
async def update_delivery(
|
||||
delivery_data: DeliveryUpdate,
|
||||
delivery_id: UUID = Path(..., description="Delivery ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update delivery information"""
|
||||
require_permissions(current_user, ["deliveries:update"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
|
||||
# Check delivery exists and belongs to tenant
|
||||
existing_delivery = await service.get_delivery(delivery_id)
|
||||
if not existing_delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
if existing_delivery.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
delivery = await service.update_delivery(
|
||||
delivery_id=delivery_id,
|
||||
delivery_data=delivery_data,
|
||||
updated_by=current_user.user_id
|
||||
)
|
||||
|
||||
if not delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
|
||||
return DeliveryResponse.from_orm(delivery)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error updating delivery", delivery_id=str(delivery_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to update delivery")
|
||||
|
||||
|
||||
@router.patch("/{delivery_id}/status", response_model=DeliveryResponse)
|
||||
async def update_delivery_status(
|
||||
status_data: DeliveryStatusUpdate,
|
||||
delivery_id: UUID = Path(..., description="Delivery ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update delivery status"""
|
||||
require_permissions(current_user, ["deliveries:update"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
|
||||
# Check delivery exists and belongs to tenant
|
||||
existing_delivery = await service.get_delivery(delivery_id)
|
||||
if not existing_delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
if existing_delivery.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
delivery = await service.update_delivery_status(
|
||||
delivery_id=delivery_id,
|
||||
status=status_data.status,
|
||||
updated_by=current_user.user_id,
|
||||
notes=status_data.notes,
|
||||
update_timestamps=status_data.update_timestamps
|
||||
)
|
||||
|
||||
if not delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
|
||||
return DeliveryResponse.from_orm(delivery)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error updating delivery status", delivery_id=str(delivery_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to update delivery status")
|
||||
|
||||
|
||||
@router.post("/{delivery_id}/receive", response_model=DeliveryResponse)
|
||||
async def receive_delivery(
|
||||
receipt_data: DeliveryReceiptConfirmation,
|
||||
delivery_id: UUID = Path(..., description="Delivery ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark delivery as received with inspection details"""
|
||||
require_permissions(current_user, ["deliveries:receive"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
|
||||
# Check delivery exists and belongs to tenant
|
||||
existing_delivery = await service.get_delivery(delivery_id)
|
||||
if not existing_delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
if existing_delivery.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
delivery = await service.mark_as_received(
|
||||
delivery_id=delivery_id,
|
||||
received_by=current_user.user_id,
|
||||
inspection_passed=receipt_data.inspection_passed,
|
||||
inspection_notes=receipt_data.inspection_notes,
|
||||
quality_issues=receipt_data.quality_issues,
|
||||
notes=receipt_data.notes
|
||||
)
|
||||
|
||||
if not delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
|
||||
return DeliveryResponse.from_orm(delivery)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error receiving delivery", delivery_id=str(delivery_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to receive delivery")
|
||||
|
||||
|
||||
@router.get("/purchase-order/{po_id}", response_model=List[DeliverySummary])
|
||||
async def get_deliveries_by_purchase_order(
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all deliveries for a purchase order"""
|
||||
require_permissions(current_user, ["deliveries:read"])
|
||||
|
||||
try:
|
||||
service = DeliveryService(db)
|
||||
deliveries = await service.get_deliveries_by_purchase_order(po_id)
|
||||
|
||||
# Check tenant access for first delivery (all should belong to same tenant)
|
||||
if deliveries and deliveries[0].tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting deliveries by purchase order", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve deliveries for purchase order")
|
||||
510
services/suppliers/app/api/purchase_orders.py
Normal file
510
services/suppliers/app/api/purchase_orders.py
Normal file
@@ -0,0 +1,510 @@
|
||||
# services/suppliers/app/api/purchase_orders.py
|
||||
"""
|
||||
Purchase Order API endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.services.purchase_order_service import PurchaseOrderService
|
||||
from app.schemas.suppliers import (
|
||||
PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderResponse, PurchaseOrderSummary,
|
||||
PurchaseOrderSearchParams, PurchaseOrderStatusUpdate, PurchaseOrderApproval,
|
||||
PurchaseOrderStatistics
|
||||
)
|
||||
from app.models.suppliers import PurchaseOrderStatus
|
||||
from shared.auth.dependencies import get_current_user, require_permissions
|
||||
from shared.auth.models import UserInfo
|
||||
|
||||
router = APIRouter(prefix="/purchase-orders", tags=["purchase-orders"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@router.post("/", response_model=PurchaseOrderResponse)
|
||||
async def create_purchase_order(
|
||||
po_data: PurchaseOrderCreate,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new purchase order"""
|
||||
require_permissions(current_user, ["purchase_orders:create"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
purchase_order = await service.create_purchase_order(
|
||||
tenant_id=current_user.tenant_id,
|
||||
po_data=po_data,
|
||||
created_by=current_user.user_id
|
||||
)
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error creating purchase order", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to create purchase order")
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PurchaseOrderSummary])
|
||||
async def list_purchase_orders(
|
||||
supplier_id: Optional[UUID] = Query(None, description="Filter by supplier ID"),
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
priority: Optional[str] = Query(None, description="Filter by priority"),
|
||||
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)"),
|
||||
search_term: Optional[str] = Query(None, description="Search term"),
|
||||
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"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List purchase orders with optional filters"""
|
||||
require_permissions(current_user, ["purchase_orders:read"])
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# Parse date filters
|
||||
date_from_parsed = None
|
||||
date_to_parsed = None
|
||||
if date_from:
|
||||
try:
|
||||
date_from_parsed = datetime.fromisoformat(date_from)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_from format")
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
date_to_parsed = datetime.fromisoformat(date_to)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date_to format")
|
||||
|
||||
# Validate status
|
||||
status_enum = None
|
||||
if status:
|
||||
try:
|
||||
status_enum = PurchaseOrderStatus(status.upper())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
service = PurchaseOrderService(db)
|
||||
search_params = PurchaseOrderSearchParams(
|
||||
supplier_id=supplier_id,
|
||||
status=status_enum,
|
||||
priority=priority,
|
||||
date_from=date_from_parsed,
|
||||
date_to=date_to_parsed,
|
||||
search_term=search_term,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
orders = await service.search_purchase_orders(
|
||||
tenant_id=current_user.tenant_id,
|
||||
search_params=search_params
|
||||
)
|
||||
|
||||
return [PurchaseOrderSummary.from_orm(order) for order in orders]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error listing purchase orders", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve purchase orders")
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=PurchaseOrderStatistics)
|
||||
async def get_purchase_order_statistics(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get purchase order statistics for dashboard"""
|
||||
require_permissions(current_user, ["purchase_orders:read"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
stats = await service.get_purchase_order_statistics(current_user.tenant_id)
|
||||
return PurchaseOrderStatistics(**stats)
|
||||
except Exception as e:
|
||||
logger.error("Error getting purchase order statistics", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve statistics")
|
||||
|
||||
|
||||
@router.get("/pending-approval", response_model=List[PurchaseOrderSummary])
|
||||
async def get_orders_requiring_approval(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get purchase orders requiring approval"""
|
||||
require_permissions(current_user, ["purchase_orders:approve"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
orders = await service.get_orders_requiring_approval(current_user.tenant_id)
|
||||
return [PurchaseOrderSummary.from_orm(order) for order in orders]
|
||||
except Exception as e:
|
||||
logger.error("Error getting orders requiring approval", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve orders requiring approval")
|
||||
|
||||
|
||||
@router.get("/overdue", response_model=List[PurchaseOrderSummary])
|
||||
async def get_overdue_orders(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get overdue purchase orders"""
|
||||
require_permissions(current_user, ["purchase_orders:read"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
orders = await service.get_overdue_orders(current_user.tenant_id)
|
||||
return [PurchaseOrderSummary.from_orm(order) for order in orders]
|
||||
except Exception as e:
|
||||
logger.error("Error getting overdue orders", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve overdue orders")
|
||||
|
||||
|
||||
@router.get("/{po_id}", response_model=PurchaseOrderResponse)
|
||||
async def get_purchase_order(
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get purchase order by ID with items"""
|
||||
require_permissions(current_user, ["purchase_orders:read"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
purchase_order = await service.get_purchase_order(po_id)
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
# Check tenant access
|
||||
if purchase_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting purchase order", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve purchase order")
|
||||
|
||||
|
||||
@router.put("/{po_id}", response_model=PurchaseOrderResponse)
|
||||
async def update_purchase_order(
|
||||
po_data: PurchaseOrderUpdate,
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update purchase order information"""
|
||||
require_permissions(current_user, ["purchase_orders:update"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
purchase_order = await service.update_purchase_order(
|
||||
po_id=po_id,
|
||||
po_data=po_data,
|
||||
updated_by=current_user.user_id
|
||||
)
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error updating purchase order", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to update purchase order")
|
||||
|
||||
|
||||
@router.patch("/{po_id}/status", response_model=PurchaseOrderResponse)
|
||||
async def update_purchase_order_status(
|
||||
status_data: PurchaseOrderStatusUpdate,
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update purchase order status"""
|
||||
require_permissions(current_user, ["purchase_orders:update"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
purchase_order = await service.update_order_status(
|
||||
po_id=po_id,
|
||||
status=status_data.status,
|
||||
updated_by=current_user.user_id,
|
||||
notes=status_data.notes
|
||||
)
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error updating purchase order status", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to update purchase order status")
|
||||
|
||||
|
||||
@router.post("/{po_id}/approve", response_model=PurchaseOrderResponse)
|
||||
async def approve_purchase_order(
|
||||
approval_data: PurchaseOrderApproval,
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Approve or reject a purchase order"""
|
||||
require_permissions(current_user, ["purchase_orders:approve"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if approval_data.action == "approve":
|
||||
purchase_order = await service.approve_purchase_order(
|
||||
po_id=po_id,
|
||||
approved_by=current_user.user_id,
|
||||
approval_notes=approval_data.notes
|
||||
)
|
||||
elif approval_data.action == "reject":
|
||||
if not approval_data.notes:
|
||||
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||
purchase_order = await service.reject_purchase_order(
|
||||
po_id=po_id,
|
||||
rejection_reason=approval_data.notes,
|
||||
rejected_by=current_user.user_id
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Purchase order is not in pending approval status"
|
||||
)
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error processing purchase order approval", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to process purchase order approval")
|
||||
|
||||
|
||||
@router.post("/{po_id}/send-to-supplier", response_model=PurchaseOrderResponse)
|
||||
async def send_to_supplier(
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
send_email: bool = Query(True, description="Send email notification to supplier"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send purchase order to supplier"""
|
||||
require_permissions(current_user, ["purchase_orders:send"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
purchase_order = await service.send_to_supplier(
|
||||
po_id=po_id,
|
||||
sent_by=current_user.user_id,
|
||||
send_email=send_email
|
||||
)
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error sending purchase order to supplier", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to send purchase order to supplier")
|
||||
|
||||
|
||||
@router.post("/{po_id}/confirm-supplier-receipt", response_model=PurchaseOrderResponse)
|
||||
async def confirm_supplier_receipt(
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
supplier_reference: Optional[str] = Query(None, description="Supplier's order reference"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Confirm supplier has received and accepted the order"""
|
||||
require_permissions(current_user, ["purchase_orders:update"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
purchase_order = await service.confirm_supplier_receipt(
|
||||
po_id=po_id,
|
||||
supplier_reference=supplier_reference,
|
||||
confirmed_by=current_user.user_id
|
||||
)
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error confirming supplier receipt", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to confirm supplier receipt")
|
||||
|
||||
|
||||
@router.post("/{po_id}/cancel", response_model=PurchaseOrderResponse)
|
||||
async def cancel_purchase_order(
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
cancellation_reason: str = Query(..., description="Reason for cancellation"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Cancel a purchase order"""
|
||||
require_permissions(current_user, ["purchase_orders:cancel"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
purchase_order = await service.cancel_purchase_order(
|
||||
po_id=po_id,
|
||||
cancellation_reason=cancellation_reason,
|
||||
cancelled_by=current_user.user_id
|
||||
)
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error cancelling purchase order", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to cancel purchase order")
|
||||
|
||||
|
||||
@router.get("/supplier/{supplier_id}", response_model=List[PurchaseOrderSummary])
|
||||
async def get_orders_by_supplier(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Number of orders to return"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get recent purchase orders for a specific supplier"""
|
||||
require_permissions(current_user, ["purchase_orders:read"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
orders = await service.get_orders_by_supplier(
|
||||
tenant_id=current_user.tenant_id,
|
||||
supplier_id=supplier_id,
|
||||
limit=limit
|
||||
)
|
||||
return [PurchaseOrderSummary.from_orm(order) for order in orders]
|
||||
except Exception as e:
|
||||
logger.error("Error getting orders by supplier", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve orders by supplier")
|
||||
|
||||
|
||||
@router.get("/ingredients/{ingredient_id}/history")
|
||||
async def get_ingredient_purchase_history(
|
||||
ingredient_id: UUID = Path(..., description="Ingredient ID"),
|
||||
days_back: int = Query(90, ge=1, le=365, description="Number of days to look back"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get purchase history for a specific ingredient"""
|
||||
require_permissions(current_user, ["purchase_orders:read"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
history = await service.get_ingredient_purchase_history(
|
||||
tenant_id=current_user.tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
days_back=days_back
|
||||
)
|
||||
return history
|
||||
except Exception as e:
|
||||
logger.error("Error getting ingredient purchase history", ingredient_id=str(ingredient_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve ingredient purchase history")
|
||||
|
||||
|
||||
@router.get("/ingredients/top-purchased")
|
||||
async def get_top_purchased_ingredients(
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to look back"),
|
||||
limit: int = Query(10, ge=1, le=50, description="Number of top ingredients to return"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get most purchased ingredients by value"""
|
||||
require_permissions(current_user, ["purchase_orders:read"])
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
ingredients = await service.get_top_purchased_ingredients(
|
||||
tenant_id=current_user.tenant_id,
|
||||
days_back=days_back,
|
||||
limit=limit
|
||||
)
|
||||
return ingredients
|
||||
except Exception as e:
|
||||
logger.error("Error getting top purchased ingredients", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve top purchased ingredients")
|
||||
324
services/suppliers/app/api/suppliers.py
Normal file
324
services/suppliers/app/api/suppliers.py
Normal file
@@ -0,0 +1,324 @@
|
||||
# services/suppliers/app/api/suppliers.py
|
||||
"""
|
||||
Supplier API endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.services.supplier_service import SupplierService
|
||||
from app.schemas.suppliers import (
|
||||
SupplierCreate, SupplierUpdate, SupplierResponse, SupplierSummary,
|
||||
SupplierSearchParams, SupplierApproval, SupplierStatistics
|
||||
)
|
||||
from shared.auth.dependencies import get_current_user, require_permissions
|
||||
from shared.auth.models import UserInfo
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@router.post("/", response_model=SupplierResponse)
|
||||
async def create_supplier(
|
||||
supplier_data: SupplierCreate,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new supplier"""
|
||||
require_permissions(current_user, ["suppliers:create"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
supplier = await service.create_supplier(
|
||||
tenant_id=current_user.tenant_id,
|
||||
supplier_data=supplier_data,
|
||||
created_by=current_user.user_id
|
||||
)
|
||||
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("/", response_model=List[SupplierSummary])
|
||||
async def list_suppliers(
|
||||
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"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List suppliers with optional filters"""
|
||||
require_permissions(current_user, ["suppliers:read"])
|
||||
|
||||
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=current_user.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("/statistics", response_model=SupplierStatistics)
|
||||
async def get_supplier_statistics(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get supplier statistics for dashboard"""
|
||||
require_permissions(current_user, ["suppliers:read"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
stats = await service.get_supplier_statistics(current_user.tenant_id)
|
||||
return SupplierStatistics(**stats)
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier statistics", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve statistics")
|
||||
|
||||
|
||||
@router.get("/active", response_model=List[SupplierSummary])
|
||||
async def get_active_suppliers(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all active suppliers"""
|
||||
require_permissions(current_user, ["suppliers:read"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_active_suppliers(current_user.tenant_id)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except Exception as e:
|
||||
logger.error("Error getting active suppliers", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve active suppliers")
|
||||
|
||||
|
||||
@router.get("/top", response_model=List[SupplierSummary])
|
||||
async def get_top_suppliers(
|
||||
limit: int = Query(10, ge=1, le=50, description="Number of top suppliers to return"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get top performing suppliers"""
|
||||
require_permissions(current_user, ["suppliers:read"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_top_suppliers(current_user.tenant_id, limit)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except Exception as e:
|
||||
logger.error("Error getting top suppliers", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve top suppliers")
|
||||
|
||||
|
||||
@router.get("/pending-review", response_model=List[SupplierSummary])
|
||||
async def get_suppliers_needing_review(
|
||||
days_since_last_order: int = Query(30, ge=1, le=365, description="Days since last order"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get suppliers that may need performance review"""
|
||||
require_permissions(current_user, ["suppliers:read"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_suppliers_needing_review(
|
||||
current_user.tenant_id, days_since_last_order
|
||||
)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except Exception as e:
|
||||
logger.error("Error getting suppliers needing review", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers needing review")
|
||||
|
||||
|
||||
@router.get("/{supplier_id}", response_model=SupplierResponse)
|
||||
async def get_supplier(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get supplier by ID"""
|
||||
require_permissions(current_user, ["suppliers:read"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
supplier = await service.get_supplier(supplier_id)
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
# Check tenant access
|
||||
if supplier.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
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("/{supplier_id}", response_model=SupplierResponse)
|
||||
async def update_supplier(
|
||||
supplier_data: SupplierUpdate,
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update supplier information"""
|
||||
require_permissions(current_user, ["suppliers:update"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Check supplier exists and belongs to tenant
|
||||
existing_supplier = await service.get_supplier(supplier_id)
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
if existing_supplier.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
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("/{supplier_id}")
|
||||
async def delete_supplier(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete supplier (soft delete)"""
|
||||
require_permissions(current_user, ["suppliers:delete"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Check supplier exists and belongs to tenant
|
||||
existing_supplier = await service.get_supplier(supplier_id)
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
if existing_supplier.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
success = await service.delete_supplier(supplier_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
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.post("/{supplier_id}/approve", response_model=SupplierResponse)
|
||||
async def approve_supplier(
|
||||
approval_data: SupplierApproval,
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Approve or reject a pending supplier"""
|
||||
require_permissions(current_user, ["suppliers:approve"])
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
# Check supplier exists and belongs to tenant
|
||||
existing_supplier = await service.get_supplier(supplier_id)
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
if existing_supplier.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if approval_data.action == "approve":
|
||||
supplier = await service.approve_supplier(
|
||||
supplier_id=supplier_id,
|
||||
approved_by=current_user.user_id,
|
||||
notes=approval_data.notes
|
||||
)
|
||||
elif approval_data.action == "reject":
|
||||
if not approval_data.notes:
|
||||
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||
supplier = await service.reject_supplier(
|
||||
supplier_id=supplier_id,
|
||||
rejection_reason=approval_data.notes,
|
||||
rejected_by=current_user.user_id
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=400, detail="Supplier is not in pending approval status")
|
||||
|
||||
return SupplierResponse.from_orm(supplier)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error processing supplier approval", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to process supplier approval")
|
||||
|
||||
|
||||
@router.get("/types/{supplier_type}", response_model=List[SupplierSummary])
|
||||
async def get_suppliers_by_type(
|
||||
supplier_type: str = Path(..., description="Supplier type"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get suppliers by type"""
|
||||
require_permissions(current_user, ["suppliers:read"])
|
||||
|
||||
try:
|
||||
from app.models.suppliers import SupplierType
|
||||
|
||||
# Validate supplier type
|
||||
try:
|
||||
type_enum = SupplierType(supplier_type.upper())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid supplier type")
|
||||
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_suppliers_by_type(current_user.tenant_id, type_enum)
|
||||
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting suppliers by type", supplier_type=supplier_type, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers by type")
|
||||
1
services/suppliers/app/core/__init__.py
Normal file
1
services/suppliers/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/core/__init__.py
|
||||
84
services/suppliers/app/core/config.py
Normal file
84
services/suppliers/app/core/config.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# services/suppliers/app/core/config.py
|
||||
"""
|
||||
Supplier & Procurement Service Configuration
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from pydantic import Field
|
||||
from shared.config.base import BaseServiceSettings
|
||||
|
||||
|
||||
class Settings(BaseServiceSettings):
|
||||
"""Supplier service settings extending base configuration"""
|
||||
|
||||
# Override service-specific settings
|
||||
SERVICE_NAME: str = "suppliers-service"
|
||||
VERSION: str = "1.0.0"
|
||||
APP_NAME: str = "Bakery Supplier Service"
|
||||
DESCRIPTION: str = "Supplier and procurement management service"
|
||||
|
||||
# API Configuration
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
# Override database URL to use SUPPLIERS_DATABASE_URL
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://suppliers_user:suppliers_pass123@suppliers-db:5432/suppliers_db",
|
||||
env="SUPPLIERS_DATABASE_URL"
|
||||
)
|
||||
|
||||
# Suppliers-specific Redis database
|
||||
REDIS_DB: int = Field(default=4, env="SUPPLIERS_REDIS_DB")
|
||||
|
||||
# File upload configuration
|
||||
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB
|
||||
UPLOAD_PATH: str = Field(default="/tmp/uploads", env="SUPPLIERS_UPLOAD_PATH")
|
||||
ALLOWED_FILE_EXTENSIONS: List[str] = [".csv", ".xlsx", ".xls", ".pdf", ".png", ".jpg", ".jpeg"]
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 50
|
||||
MAX_PAGE_SIZE: int = 500
|
||||
|
||||
# Price validation
|
||||
MIN_UNIT_PRICE: float = 0.01
|
||||
MAX_UNIT_PRICE: float = 10000.0
|
||||
MIN_ORDER_AMOUNT: float = 1.0
|
||||
MAX_ORDER_AMOUNT: float = 100000.0
|
||||
|
||||
# Supplier-specific cache TTL
|
||||
SUPPLIERS_CACHE_TTL: int = 900 # 15 minutes
|
||||
PURCHASE_ORDERS_CACHE_TTL: int = 300 # 5 minutes
|
||||
DELIVERIES_CACHE_TTL: int = 180 # 3 minutes
|
||||
PRICE_LIST_CACHE_TTL: int = 1800 # 30 minutes
|
||||
|
||||
# Purchase order settings
|
||||
DEFAULT_PAYMENT_TERMS_DAYS: int = 30
|
||||
MAX_PAYMENT_TERMS_DAYS: int = 90
|
||||
DEFAULT_DELIVERY_DAYS: int = 3
|
||||
MAX_DELIVERY_DAYS: int = 30
|
||||
|
||||
# Quality and rating settings
|
||||
MIN_QUALITY_RATING: float = 1.0
|
||||
MAX_QUALITY_RATING: float = 5.0
|
||||
MIN_DELIVERY_RATING: float = 1.0
|
||||
MAX_DELIVERY_RATING: float = 5.0
|
||||
|
||||
# Lead time settings (in days)
|
||||
DEFAULT_LEAD_TIME: int = 3
|
||||
MAX_LEAD_TIME: int = 30
|
||||
|
||||
# Order approval thresholds
|
||||
AUTO_APPROVE_THRESHOLD: float = 500.0 # Amounts below this auto-approve
|
||||
MANAGER_APPROVAL_THRESHOLD: float = 2000.0 # Manager approval required
|
||||
|
||||
# Communication settings
|
||||
ORDER_CONFIRMATION_EMAIL: bool = True
|
||||
DELIVERY_NOTIFICATION_EMAIL: bool = True
|
||||
QUALITY_ISSUE_EMAIL: bool = True
|
||||
|
||||
# Business hours for supplier contact (24h format)
|
||||
BUSINESS_HOURS_START: int = 8
|
||||
BUSINESS_HOURS_END: int = 18
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
86
services/suppliers/app/core/database.py
Normal file
86
services/suppliers/app/core/database.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# services/suppliers/app/core/database.py
|
||||
"""
|
||||
Supplier Service Database Configuration using shared database manager
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.database.base import DatabaseManager, Base
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create database manager instance
|
||||
database_manager = DatabaseManager(
|
||||
database_url=settings.DATABASE_URL,
|
||||
service_name="suppliers-service",
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
echo=settings.DB_ECHO
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""
|
||||
Database dependency for FastAPI - using shared database manager
|
||||
"""
|
||||
async for session in database_manager.get_db():
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables using shared database manager"""
|
||||
try:
|
||||
logger.info("Initializing Supplier Service database...")
|
||||
|
||||
# Import all models to ensure they're registered
|
||||
from app.models import suppliers # noqa: F401
|
||||
|
||||
# Create all tables using database manager
|
||||
await database_manager.create_tables(Base.metadata)
|
||||
|
||||
logger.info("Supplier Service database initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize database", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""Close database connections using shared database manager"""
|
||||
try:
|
||||
await database_manager.close_connections()
|
||||
logger.info("Database connections closed")
|
||||
except Exception as e:
|
||||
logger.error("Error closing database connections", error=str(e))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_transaction():
|
||||
"""
|
||||
Context manager for database transactions using shared database manager
|
||||
"""
|
||||
async with database_manager.get_session() as session:
|
||||
try:
|
||||
async with session.begin():
|
||||
yield session
|
||||
except Exception as e:
|
||||
logger.error("Transaction error", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_background_session():
|
||||
"""
|
||||
Context manager for background tasks using shared database manager
|
||||
"""
|
||||
async with database_manager.get_background_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def health_check():
|
||||
"""Database health check using shared database manager"""
|
||||
return await database_manager.health_check()
|
||||
168
services/suppliers/app/main.py
Normal file
168
services/suppliers/app/main.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# services/suppliers/app/main.py
|
||||
"""
|
||||
Supplier & Procurement Service FastAPI Application
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import structlog
|
||||
|
||||
# Import core modules
|
||||
from app.core.config import settings
|
||||
from app.core.database import init_db, close_db
|
||||
from app.api import suppliers, purchase_orders, deliveries
|
||||
from shared.monitoring.health import router as health_router
|
||||
from shared.monitoring.metrics import setup_metrics
|
||||
from shared.auth.decorators import setup_auth_middleware
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan management"""
|
||||
# Startup
|
||||
logger.info("Starting Supplier Service", version=settings.VERSION)
|
||||
|
||||
try:
|
||||
# Initialize database
|
||||
await init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Setup metrics
|
||||
setup_metrics(app)
|
||||
logger.info("Metrics setup completed")
|
||||
|
||||
yield
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Startup failed", error=str(e))
|
||||
raise
|
||||
finally:
|
||||
# Shutdown
|
||||
logger.info("Shutting down Supplier Service")
|
||||
try:
|
||||
await close_db()
|
||||
logger.info("Database connections closed")
|
||||
except Exception as e:
|
||||
logger.error("Shutdown error", error=str(e))
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description=settings.DESCRIPTION,
|
||||
version=settings.VERSION,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
docs_url=f"{settings.API_V1_STR}/docs",
|
||||
redoc_url=f"{settings.API_V1_STR}/redoc",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Setup authentication middleware
|
||||
setup_auth_middleware(app)
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(request: Request, exc: ValueError):
|
||||
"""Handle validation errors"""
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "Validation Error",
|
||||
"detail": str(exc),
|
||||
"type": "value_error"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""Handle general exceptions"""
|
||||
logger.error(
|
||||
"Unhandled exception",
|
||||
error=str(exc),
|
||||
path=request.url.path,
|
||||
method=request.method
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "Internal Server Error",
|
||||
"detail": "An unexpected error occurred",
|
||||
"type": "internal_error"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(health_router, prefix="/health", tags=["health"])
|
||||
app.include_router(suppliers.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(purchase_orders.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(deliveries.router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with service information"""
|
||||
return {
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.VERSION,
|
||||
"description": settings.DESCRIPTION,
|
||||
"status": "running",
|
||||
"docs_url": f"{settings.API_V1_STR}/docs",
|
||||
"health_url": "/health"
|
||||
}
|
||||
|
||||
|
||||
# Service info endpoint
|
||||
@app.get(f"{settings.API_V1_STR}/info")
|
||||
async def service_info():
|
||||
"""Service information endpoint"""
|
||||
return {
|
||||
"service": settings.SERVICE_NAME,
|
||||
"version": settings.VERSION,
|
||||
"description": settings.DESCRIPTION,
|
||||
"api_version": "v1",
|
||||
"environment": settings.ENVIRONMENT,
|
||||
"features": [
|
||||
"supplier_management",
|
||||
"vendor_onboarding",
|
||||
"purchase_orders",
|
||||
"delivery_tracking",
|
||||
"quality_reviews",
|
||||
"price_list_management",
|
||||
"invoice_tracking",
|
||||
"supplier_ratings",
|
||||
"procurement_workflow"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=os.getenv("RELOAD", "false").lower() == "true",
|
||||
log_level="info"
|
||||
)
|
||||
1
services/suppliers/app/models/__init__.py
Normal file
1
services/suppliers/app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/models/__init__.py
|
||||
565
services/suppliers/app/models/suppliers.py
Normal file
565
services/suppliers/app/models/suppliers.py
Normal file
@@ -0,0 +1,565 @@
|
||||
# services/suppliers/app/models/suppliers.py
|
||||
"""
|
||||
Supplier & Procurement management models for Suppliers Service
|
||||
Comprehensive supplier management, purchase orders, deliveries, and vendor relationships
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Index, Boolean, Numeric, ForeignKey, Enum as SQLEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional, List
|
||||
from decimal import Decimal
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
|
||||
class SupplierType(enum.Enum):
|
||||
"""Types of suppliers"""
|
||||
INGREDIENTS = "ingredients" # Raw materials supplier
|
||||
PACKAGING = "packaging" # Packaging materials
|
||||
EQUIPMENT = "equipment" # Bakery equipment
|
||||
SERVICES = "services" # Service providers
|
||||
UTILITIES = "utilities" # Utilities (gas, electricity)
|
||||
MULTI = "multi" # Multi-category supplier
|
||||
|
||||
|
||||
class SupplierStatus(enum.Enum):
|
||||
"""Supplier lifecycle status"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
SUSPENDED = "suspended"
|
||||
BLACKLISTED = "blacklisted"
|
||||
|
||||
|
||||
class PaymentTerms(enum.Enum):
|
||||
"""Payment terms with suppliers"""
|
||||
CASH_ON_DELIVERY = "cod"
|
||||
NET_15 = "net_15"
|
||||
NET_30 = "net_30"
|
||||
NET_45 = "net_45"
|
||||
NET_60 = "net_60"
|
||||
PREPAID = "prepaid"
|
||||
CREDIT_TERMS = "credit_terms"
|
||||
|
||||
|
||||
class PurchaseOrderStatus(enum.Enum):
|
||||
"""Purchase order lifecycle status"""
|
||||
DRAFT = "draft"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
APPROVED = "approved"
|
||||
SENT_TO_SUPPLIER = "sent_to_supplier"
|
||||
CONFIRMED = "confirmed"
|
||||
PARTIALLY_RECEIVED = "partially_received"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
DISPUTED = "disputed"
|
||||
|
||||
|
||||
class DeliveryStatus(enum.Enum):
|
||||
"""Delivery status tracking"""
|
||||
SCHEDULED = "scheduled"
|
||||
IN_TRANSIT = "in_transit"
|
||||
OUT_FOR_DELIVERY = "out_for_delivery"
|
||||
DELIVERED = "delivered"
|
||||
PARTIALLY_DELIVERED = "partially_delivered"
|
||||
FAILED_DELIVERY = "failed_delivery"
|
||||
RETURNED = "returned"
|
||||
|
||||
|
||||
class QualityRating(enum.Enum):
|
||||
"""Quality rating scale"""
|
||||
EXCELLENT = 5
|
||||
GOOD = 4
|
||||
AVERAGE = 3
|
||||
POOR = 2
|
||||
VERY_POOR = 1
|
||||
|
||||
|
||||
class DeliveryRating(enum.Enum):
|
||||
"""Delivery performance rating scale"""
|
||||
EXCELLENT = 5
|
||||
GOOD = 4
|
||||
AVERAGE = 3
|
||||
POOR = 2
|
||||
VERY_POOR = 1
|
||||
|
||||
|
||||
class InvoiceStatus(enum.Enum):
|
||||
"""Invoice processing status"""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
PAID = "paid"
|
||||
OVERDUE = "overdue"
|
||||
DISPUTED = "disputed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
"""Master supplier information"""
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Basic supplier information
|
||||
name = Column(String(255), nullable=False, index=True)
|
||||
supplier_code = Column(String(50), nullable=True, index=True) # Internal reference code
|
||||
tax_id = Column(String(50), nullable=True) # VAT/Tax ID
|
||||
registration_number = Column(String(100), nullable=True) # Business registration number
|
||||
|
||||
# Supplier classification
|
||||
supplier_type = Column(SQLEnum(SupplierType), nullable=False, index=True)
|
||||
status = Column(SQLEnum(SupplierStatus), nullable=False, default=SupplierStatus.PENDING_APPROVAL, index=True)
|
||||
|
||||
# Contact information
|
||||
contact_person = Column(String(200), nullable=True)
|
||||
email = Column(String(254), nullable=True)
|
||||
phone = Column(String(30), nullable=True)
|
||||
mobile = Column(String(30), nullable=True)
|
||||
website = Column(String(255), nullable=True)
|
||||
|
||||
# Address information
|
||||
address_line1 = Column(String(255), nullable=True)
|
||||
address_line2 = Column(String(255), nullable=True)
|
||||
city = Column(String(100), nullable=True)
|
||||
state_province = Column(String(100), nullable=True)
|
||||
postal_code = Column(String(20), nullable=True)
|
||||
country = Column(String(100), nullable=True)
|
||||
|
||||
# Business terms
|
||||
payment_terms = Column(SQLEnum(PaymentTerms), nullable=False, default=PaymentTerms.NET_30)
|
||||
credit_limit = Column(Numeric(12, 2), nullable=True)
|
||||
currency = Column(String(3), nullable=False, default="EUR") # ISO currency code
|
||||
|
||||
# Lead times (in days)
|
||||
standard_lead_time = Column(Integer, nullable=False, default=3)
|
||||
minimum_order_amount = Column(Numeric(10, 2), nullable=True)
|
||||
delivery_area = Column(String(255), nullable=True)
|
||||
|
||||
# Quality and performance metrics
|
||||
quality_rating = Column(Float, nullable=True, default=0.0) # Average quality rating (1-5)
|
||||
delivery_rating = Column(Float, nullable=True, default=0.0) # Average delivery rating (1-5)
|
||||
total_orders = Column(Integer, nullable=False, default=0)
|
||||
total_amount = Column(Numeric(12, 2), nullable=False, default=0.0)
|
||||
|
||||
# Onboarding and approval
|
||||
approved_by = Column(UUID(as_uuid=True), nullable=True) # User who approved
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
rejection_reason = Column(Text, nullable=True)
|
||||
|
||||
# Additional information
|
||||
notes = Column(Text, nullable=True)
|
||||
certifications = Column(JSONB, nullable=True) # Quality certifications, licenses
|
||||
business_hours = Column(JSONB, nullable=True) # Operating hours by day
|
||||
specializations = Column(JSONB, nullable=True) # Product categories, special services
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
updated_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
# Relationships
|
||||
price_lists = relationship("SupplierPriceList", back_populates="supplier", cascade="all, delete-orphan")
|
||||
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
|
||||
quality_reviews = relationship("SupplierQualityReview", back_populates="supplier", cascade="all, delete-orphan")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_suppliers_tenant_name', 'tenant_id', 'name'),
|
||||
Index('ix_suppliers_tenant_status', 'tenant_id', 'status'),
|
||||
Index('ix_suppliers_tenant_type', 'tenant_id', 'supplier_type'),
|
||||
Index('ix_suppliers_quality_rating', 'quality_rating'),
|
||||
)
|
||||
|
||||
|
||||
class SupplierPriceList(Base):
|
||||
"""Product pricing from suppliers"""
|
||||
__tablename__ = "supplier_price_lists"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
supplier_id = Column(UUID(as_uuid=True), ForeignKey('suppliers.id'), nullable=False, index=True)
|
||||
|
||||
# Product identification (references inventory service)
|
||||
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to inventory.ingredients
|
||||
product_code = Column(String(100), nullable=True) # Supplier's product code
|
||||
product_name = Column(String(255), nullable=False)
|
||||
|
||||
# Pricing information
|
||||
unit_price = Column(Numeric(10, 4), nullable=False)
|
||||
unit_of_measure = Column(String(20), nullable=False) # kg, g, l, ml, units, etc.
|
||||
minimum_order_quantity = Column(Integer, nullable=True, default=1)
|
||||
price_per_unit = Column(Numeric(10, 4), nullable=False) # Calculated field
|
||||
|
||||
# Pricing tiers (volume discounts)
|
||||
tier_pricing = Column(JSONB, nullable=True) # [{quantity: 100, price: 2.50}, ...]
|
||||
|
||||
# Validity and terms
|
||||
effective_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
expiry_date = Column(DateTime(timezone=True), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Additional product details
|
||||
brand = Column(String(100), nullable=True)
|
||||
packaging_size = Column(String(50), nullable=True)
|
||||
origin_country = Column(String(100), nullable=True)
|
||||
shelf_life_days = Column(Integer, nullable=True)
|
||||
storage_requirements = Column(Text, nullable=True)
|
||||
|
||||
# Quality specifications
|
||||
quality_specs = Column(JSONB, nullable=True) # Quality parameters, certifications
|
||||
allergens = Column(JSONB, nullable=True) # Allergen information
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
updated_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
# Relationships
|
||||
supplier = relationship("Supplier", back_populates="price_lists")
|
||||
purchase_order_items = relationship("PurchaseOrderItem", back_populates="price_list_item")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_price_lists_tenant_supplier', 'tenant_id', 'supplier_id'),
|
||||
Index('ix_price_lists_ingredient', 'ingredient_id'),
|
||||
Index('ix_price_lists_active', 'is_active'),
|
||||
Index('ix_price_lists_effective_date', 'effective_date'),
|
||||
)
|
||||
|
||||
|
||||
class PurchaseOrder(Base):
|
||||
"""Purchase orders to suppliers"""
|
||||
__tablename__ = "purchase_orders"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
supplier_id = Column(UUID(as_uuid=True), ForeignKey('suppliers.id'), nullable=False, index=True)
|
||||
|
||||
# Order identification
|
||||
po_number = Column(String(50), nullable=False, index=True) # Human-readable PO number
|
||||
reference_number = Column(String(100), nullable=True) # Internal reference
|
||||
|
||||
# Order status and workflow
|
||||
status = Column(SQLEnum(PurchaseOrderStatus), nullable=False, default=PurchaseOrderStatus.DRAFT, index=True)
|
||||
priority = Column(String(20), nullable=False, default="normal") # urgent, high, normal, low
|
||||
|
||||
# Order details
|
||||
order_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
required_delivery_date = Column(DateTime(timezone=True), nullable=True)
|
||||
estimated_delivery_date = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Financial information
|
||||
subtotal = Column(Numeric(12, 2), nullable=False, default=0.0)
|
||||
tax_amount = Column(Numeric(12, 2), nullable=False, default=0.0)
|
||||
shipping_cost = Column(Numeric(10, 2), nullable=False, default=0.0)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
|
||||
total_amount = Column(Numeric(12, 2), nullable=False, default=0.0)
|
||||
currency = Column(String(3), nullable=False, default="EUR")
|
||||
|
||||
# Delivery information
|
||||
delivery_address = Column(Text, nullable=True) # Override default address
|
||||
delivery_instructions = Column(Text, nullable=True)
|
||||
delivery_contact = Column(String(200), nullable=True)
|
||||
delivery_phone = Column(String(30), nullable=True)
|
||||
|
||||
# Approval workflow
|
||||
requires_approval = Column(Boolean, nullable=False, default=False)
|
||||
approved_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
rejection_reason = Column(Text, nullable=True)
|
||||
|
||||
# Communication tracking
|
||||
sent_to_supplier_at = Column(DateTime(timezone=True), nullable=True)
|
||||
supplier_confirmation_date = Column(DateTime(timezone=True), nullable=True)
|
||||
supplier_reference = Column(String(100), nullable=True) # Supplier's order reference
|
||||
|
||||
# Additional information
|
||||
notes = Column(Text, nullable=True)
|
||||
internal_notes = Column(Text, nullable=True) # Not shared with supplier
|
||||
terms_and_conditions = Column(Text, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
updated_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
# Relationships
|
||||
supplier = relationship("Supplier", back_populates="purchase_orders")
|
||||
items = relationship("PurchaseOrderItem", back_populates="purchase_order", cascade="all, delete-orphan")
|
||||
deliveries = relationship("Delivery", back_populates="purchase_order")
|
||||
invoices = relationship("SupplierInvoice", back_populates="purchase_order")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_purchase_orders_tenant_supplier', 'tenant_id', 'supplier_id'),
|
||||
Index('ix_purchase_orders_tenant_status', 'tenant_id', 'status'),
|
||||
Index('ix_purchase_orders_po_number', 'po_number'),
|
||||
Index('ix_purchase_orders_order_date', 'order_date'),
|
||||
Index('ix_purchase_orders_delivery_date', 'required_delivery_date'),
|
||||
)
|
||||
|
||||
|
||||
class PurchaseOrderItem(Base):
|
||||
"""Individual items within purchase orders"""
|
||||
__tablename__ = "purchase_order_items"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
purchase_order_id = Column(UUID(as_uuid=True), ForeignKey('purchase_orders.id'), nullable=False, index=True)
|
||||
price_list_item_id = Column(UUID(as_uuid=True), ForeignKey('supplier_price_lists.id'), nullable=True, index=True)
|
||||
|
||||
# Product identification
|
||||
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to inventory.ingredients
|
||||
product_code = Column(String(100), nullable=True) # Supplier's product code
|
||||
product_name = Column(String(255), nullable=False)
|
||||
|
||||
# Order quantities
|
||||
ordered_quantity = Column(Integer, nullable=False)
|
||||
unit_of_measure = Column(String(20), nullable=False)
|
||||
unit_price = Column(Numeric(10, 4), nullable=False)
|
||||
line_total = Column(Numeric(12, 2), nullable=False)
|
||||
|
||||
# Delivery tracking
|
||||
received_quantity = Column(Integer, nullable=False, default=0)
|
||||
remaining_quantity = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Quality and notes
|
||||
quality_requirements = Column(Text, nullable=True)
|
||||
item_notes = Column(Text, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
purchase_order = relationship("PurchaseOrder", back_populates="items")
|
||||
price_list_item = relationship("SupplierPriceList", back_populates="purchase_order_items")
|
||||
delivery_items = relationship("DeliveryItem", back_populates="purchase_order_item")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_po_items_tenant_po', 'tenant_id', 'purchase_order_id'),
|
||||
Index('ix_po_items_ingredient', 'ingredient_id'),
|
||||
)
|
||||
|
||||
|
||||
class Delivery(Base):
|
||||
"""Delivery tracking for purchase orders"""
|
||||
__tablename__ = "deliveries"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
purchase_order_id = Column(UUID(as_uuid=True), ForeignKey('purchase_orders.id'), nullable=False, index=True)
|
||||
supplier_id = Column(UUID(as_uuid=True), ForeignKey('suppliers.id'), nullable=False, index=True)
|
||||
|
||||
# Delivery identification
|
||||
delivery_number = Column(String(50), nullable=False, index=True)
|
||||
supplier_delivery_note = Column(String(100), nullable=True) # Supplier's delivery reference
|
||||
|
||||
# Delivery status and tracking
|
||||
status = Column(SQLEnum(DeliveryStatus), nullable=False, default=DeliveryStatus.SCHEDULED, index=True)
|
||||
|
||||
# Scheduling and timing
|
||||
scheduled_date = Column(DateTime(timezone=True), nullable=True)
|
||||
estimated_arrival = Column(DateTime(timezone=True), nullable=True)
|
||||
actual_arrival = Column(DateTime(timezone=True), nullable=True)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Delivery details
|
||||
delivery_address = Column(Text, nullable=True)
|
||||
delivery_contact = Column(String(200), nullable=True)
|
||||
delivery_phone = Column(String(30), nullable=True)
|
||||
carrier_name = Column(String(200), nullable=True)
|
||||
tracking_number = Column(String(100), nullable=True)
|
||||
|
||||
# Quality inspection
|
||||
inspection_passed = Column(Boolean, nullable=True)
|
||||
inspection_notes = Column(Text, nullable=True)
|
||||
quality_issues = Column(JSONB, nullable=True) # Documented quality problems
|
||||
|
||||
# Received by information
|
||||
received_by = Column(UUID(as_uuid=True), nullable=True) # User who received the delivery
|
||||
received_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Additional information
|
||||
notes = Column(Text, nullable=True)
|
||||
photos = Column(JSONB, nullable=True) # Photo URLs for documentation
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
# Relationships
|
||||
purchase_order = relationship("PurchaseOrder", back_populates="deliveries")
|
||||
supplier = relationship("Supplier")
|
||||
items = relationship("DeliveryItem", back_populates="delivery", cascade="all, delete-orphan")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_deliveries_tenant_status', 'tenant_id', 'status'),
|
||||
Index('ix_deliveries_scheduled_date', 'scheduled_date'),
|
||||
Index('ix_deliveries_delivery_number', 'delivery_number'),
|
||||
)
|
||||
|
||||
|
||||
class DeliveryItem(Base):
|
||||
"""Individual items within deliveries"""
|
||||
__tablename__ = "delivery_items"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
delivery_id = Column(UUID(as_uuid=True), ForeignKey('deliveries.id'), nullable=False, index=True)
|
||||
purchase_order_item_id = Column(UUID(as_uuid=True), ForeignKey('purchase_order_items.id'), nullable=False, index=True)
|
||||
|
||||
# Product identification
|
||||
ingredient_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
product_name = Column(String(255), nullable=False)
|
||||
|
||||
# Delivery quantities
|
||||
ordered_quantity = Column(Integer, nullable=False)
|
||||
delivered_quantity = Column(Integer, nullable=False)
|
||||
accepted_quantity = Column(Integer, nullable=False)
|
||||
rejected_quantity = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Quality information
|
||||
batch_lot_number = Column(String(100), nullable=True)
|
||||
expiry_date = Column(DateTime(timezone=True), nullable=True)
|
||||
quality_grade = Column(String(20), nullable=True)
|
||||
|
||||
# Issues and notes
|
||||
quality_issues = Column(Text, nullable=True)
|
||||
rejection_reason = Column(Text, nullable=True)
|
||||
item_notes = Column(Text, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
delivery = relationship("Delivery", back_populates="items")
|
||||
purchase_order_item = relationship("PurchaseOrderItem", back_populates="delivery_items")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_delivery_items_tenant_delivery', 'tenant_id', 'delivery_id'),
|
||||
Index('ix_delivery_items_ingredient', 'ingredient_id'),
|
||||
)
|
||||
|
||||
|
||||
class SupplierQualityReview(Base):
|
||||
"""Quality and performance reviews for suppliers"""
|
||||
__tablename__ = "supplier_quality_reviews"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
supplier_id = Column(UUID(as_uuid=True), ForeignKey('suppliers.id'), nullable=False, index=True)
|
||||
purchase_order_id = Column(UUID(as_uuid=True), ForeignKey('purchase_orders.id'), nullable=True, index=True)
|
||||
delivery_id = Column(UUID(as_uuid=True), ForeignKey('deliveries.id'), nullable=True, index=True)
|
||||
|
||||
# Review details
|
||||
review_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
review_type = Column(String(50), nullable=False) # delivery, monthly, annual, incident
|
||||
|
||||
# Ratings (1-5 scale)
|
||||
quality_rating = Column(SQLEnum(QualityRating), nullable=False)
|
||||
delivery_rating = Column(SQLEnum(DeliveryRating), nullable=False)
|
||||
communication_rating = Column(Integer, nullable=False) # 1-5
|
||||
overall_rating = Column(Float, nullable=False) # Calculated average
|
||||
|
||||
# Detailed feedback
|
||||
quality_comments = Column(Text, nullable=True)
|
||||
delivery_comments = Column(Text, nullable=True)
|
||||
communication_comments = Column(Text, nullable=True)
|
||||
improvement_suggestions = Column(Text, nullable=True)
|
||||
|
||||
# Issues and corrective actions
|
||||
quality_issues = Column(JSONB, nullable=True) # Documented issues
|
||||
corrective_actions = Column(Text, nullable=True)
|
||||
follow_up_required = Column(Boolean, nullable=False, default=False)
|
||||
follow_up_date = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Review status
|
||||
is_final = Column(Boolean, nullable=False, default=True)
|
||||
approved_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
reviewed_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
# Relationships
|
||||
supplier = relationship("Supplier", back_populates="quality_reviews")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_quality_reviews_tenant_supplier', 'tenant_id', 'supplier_id'),
|
||||
Index('ix_quality_reviews_date', 'review_date'),
|
||||
Index('ix_quality_reviews_overall_rating', 'overall_rating'),
|
||||
)
|
||||
|
||||
|
||||
class SupplierInvoice(Base):
|
||||
"""Invoices from suppliers"""
|
||||
__tablename__ = "supplier_invoices"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
supplier_id = Column(UUID(as_uuid=True), ForeignKey('suppliers.id'), nullable=False, index=True)
|
||||
purchase_order_id = Column(UUID(as_uuid=True), ForeignKey('purchase_orders.id'), nullable=True, index=True)
|
||||
|
||||
# Invoice identification
|
||||
invoice_number = Column(String(50), nullable=False, index=True)
|
||||
supplier_invoice_number = Column(String(100), nullable=False)
|
||||
|
||||
# Invoice status and dates
|
||||
status = Column(SQLEnum(InvoiceStatus), nullable=False, default=InvoiceStatus.PENDING, index=True)
|
||||
invoice_date = Column(DateTime(timezone=True), nullable=False)
|
||||
due_date = Column(DateTime(timezone=True), nullable=False)
|
||||
received_date = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Financial information
|
||||
subtotal = Column(Numeric(12, 2), nullable=False)
|
||||
tax_amount = Column(Numeric(12, 2), nullable=False, default=0.0)
|
||||
shipping_cost = Column(Numeric(10, 2), nullable=False, default=0.0)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
|
||||
total_amount = Column(Numeric(12, 2), nullable=False)
|
||||
currency = Column(String(3), nullable=False, default="EUR")
|
||||
|
||||
# Payment tracking
|
||||
paid_amount = Column(Numeric(12, 2), nullable=False, default=0.0)
|
||||
payment_date = Column(DateTime(timezone=True), nullable=True)
|
||||
payment_reference = Column(String(100), nullable=True)
|
||||
|
||||
# Invoice validation
|
||||
approved_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
rejection_reason = Column(Text, nullable=True)
|
||||
|
||||
# Additional information
|
||||
notes = Column(Text, nullable=True)
|
||||
invoice_document_url = Column(String(500), nullable=True) # PDF storage location
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
created_by = Column(UUID(as_uuid=True), nullable=False)
|
||||
|
||||
# Relationships
|
||||
supplier = relationship("Supplier")
|
||||
purchase_order = relationship("PurchaseOrder", back_populates="invoices")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_invoices_tenant_supplier', 'tenant_id', 'supplier_id'),
|
||||
Index('ix_invoices_tenant_status', 'tenant_id', 'status'),
|
||||
Index('ix_invoices_due_date', 'due_date'),
|
||||
Index('ix_invoices_invoice_number', 'invoice_number'),
|
||||
)
|
||||
1
services/suppliers/app/repositories/__init__.py
Normal file
1
services/suppliers/app/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/repositories/__init__.py
|
||||
96
services/suppliers/app/repositories/base.py
Normal file
96
services/suppliers/app/repositories/base.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# services/suppliers/app/repositories/base.py
|
||||
"""
|
||||
Base repository class for common database operations
|
||||
"""
|
||||
|
||||
from typing import TypeVar, Generic, List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, asc
|
||||
from uuid import UUID
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class BaseRepository(Generic[T]):
|
||||
"""Base repository with common CRUD operations"""
|
||||
|
||||
def __init__(self, model: type, db: Session):
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
def create(self, obj_data: Dict[str, Any]) -> T:
|
||||
"""Create a new record"""
|
||||
db_obj = self.model(**obj_data)
|
||||
self.db.add(db_obj)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def get_by_id(self, record_id: UUID) -> Optional[T]:
|
||||
"""Get record by ID"""
|
||||
return self.db.query(self.model).filter(self.model.id == record_id).first()
|
||||
|
||||
def get_by_tenant_id(self, tenant_id: UUID, limit: int = 100, offset: int = 0) -> List[T]:
|
||||
"""Get records by tenant ID with pagination"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update(self, record_id: UUID, update_data: Dict[str, Any]) -> Optional[T]:
|
||||
"""Update record by ID"""
|
||||
db_obj = self.get_by_id(record_id)
|
||||
if db_obj:
|
||||
for key, value in update_data.items():
|
||||
if hasattr(db_obj, key):
|
||||
setattr(db_obj, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def delete(self, record_id: UUID) -> bool:
|
||||
"""Delete record by ID"""
|
||||
db_obj = self.get_by_id(record_id)
|
||||
if db_obj:
|
||||
self.db.delete(db_obj)
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||
"""Count records by tenant"""
|
||||
return self.db.query(self.model).filter(self.model.tenant_id == tenant_id).count()
|
||||
|
||||
def list_with_filters(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[T]:
|
||||
"""List records with filtering and sorting"""
|
||||
query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id)
|
||||
|
||||
# Apply filters
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
if hasattr(self.model, key) and value is not None:
|
||||
query = query.filter(getattr(self.model, key) == value)
|
||||
|
||||
# Apply sorting
|
||||
if hasattr(self.model, sort_by):
|
||||
if sort_order.lower() == "desc":
|
||||
query = query.order_by(desc(getattr(self.model, sort_by)))
|
||||
else:
|
||||
query = query.order_by(asc(getattr(self.model, sort_by)))
|
||||
|
||||
return query.limit(limit).offset(offset).all()
|
||||
|
||||
def exists(self, record_id: UUID) -> bool:
|
||||
"""Check if record exists"""
|
||||
return self.db.query(self.model).filter(self.model.id == record_id).first() is not None
|
||||
414
services/suppliers/app/repositories/delivery_repository.py
Normal file
414
services/suppliers/app/repositories/delivery_repository.py
Normal file
@@ -0,0 +1,414 @@
|
||||
# services/suppliers/app/repositories/delivery_repository.py
|
||||
"""
|
||||
Delivery repository for database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.suppliers import (
|
||||
Delivery, DeliveryItem, DeliveryStatus,
|
||||
PurchaseOrder, Supplier
|
||||
)
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class DeliveryRepository(BaseRepository[Delivery]):
|
||||
"""Repository for delivery tracking operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(Delivery, db)
|
||||
|
||||
def get_by_delivery_number(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
delivery_number: str
|
||||
) -> Optional[Delivery]:
|
||||
"""Get delivery by delivery number within tenant"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.delivery_number == delivery_number
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_with_items(self, delivery_id: UUID) -> Optional[Delivery]:
|
||||
"""Get delivery with all items loaded"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(
|
||||
joinedload(self.model.items),
|
||||
joinedload(self.model.purchase_order),
|
||||
joinedload(self.model.supplier)
|
||||
)
|
||||
.filter(self.model.id == delivery_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_by_purchase_order(self, po_id: UUID) -> List[Delivery]:
|
||||
"""Get all deliveries for a purchase order"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.items))
|
||||
.filter(self.model.purchase_order_id == po_id)
|
||||
.order_by(self.model.scheduled_date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def search_deliveries(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_id: Optional[UUID] = None,
|
||||
status: Optional[DeliveryStatus] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
search_term: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Delivery]:
|
||||
"""Search deliveries with comprehensive filters"""
|
||||
query = (
|
||||
self.db.query(self.model)
|
||||
.options(
|
||||
joinedload(self.model.supplier),
|
||||
joinedload(self.model.purchase_order)
|
||||
)
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
)
|
||||
|
||||
# Supplier filter
|
||||
if supplier_id:
|
||||
query = query.filter(self.model.supplier_id == supplier_id)
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(self.model.status == status)
|
||||
|
||||
# Date range filter (scheduled date)
|
||||
if date_from:
|
||||
query = query.filter(self.model.scheduled_date >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(self.model.scheduled_date <= date_to)
|
||||
|
||||
# Search term filter
|
||||
if search_term:
|
||||
search_filter = or_(
|
||||
self.model.delivery_number.ilike(f"%{search_term}%"),
|
||||
self.model.supplier_delivery_note.ilike(f"%{search_term}%"),
|
||||
self.model.tracking_number.ilike(f"%{search_term}%"),
|
||||
self.model.purchase_order.has(PurchaseOrder.po_number.ilike(f"%{search_term}%"))
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
return (
|
||||
query.order_by(desc(self.model.scheduled_date))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_scheduled_deliveries(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None
|
||||
) -> List[Delivery]:
|
||||
"""Get scheduled deliveries for a date range"""
|
||||
if not date_from:
|
||||
date_from = datetime.utcnow()
|
||||
if not date_to:
|
||||
date_to = date_from + timedelta(days=7) # Next week
|
||||
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(
|
||||
joinedload(self.model.supplier),
|
||||
joinedload(self.model.purchase_order)
|
||||
)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status.in_([
|
||||
DeliveryStatus.SCHEDULED,
|
||||
DeliveryStatus.IN_TRANSIT,
|
||||
DeliveryStatus.OUT_FOR_DELIVERY
|
||||
]),
|
||||
self.model.scheduled_date >= date_from,
|
||||
self.model.scheduled_date <= date_to
|
||||
)
|
||||
)
|
||||
.order_by(self.model.scheduled_date)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_todays_deliveries(self, tenant_id: UUID) -> List[Delivery]:
|
||||
"""Get deliveries scheduled for today"""
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
|
||||
return self.get_scheduled_deliveries(tenant_id, today_start, today_end)
|
||||
|
||||
def get_overdue_deliveries(self, tenant_id: UUID) -> List[Delivery]:
|
||||
"""Get deliveries that are overdue (scheduled in the past but not completed)"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(
|
||||
joinedload(self.model.supplier),
|
||||
joinedload(self.model.purchase_order)
|
||||
)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status.in_([
|
||||
DeliveryStatus.SCHEDULED,
|
||||
DeliveryStatus.IN_TRANSIT,
|
||||
DeliveryStatus.OUT_FOR_DELIVERY
|
||||
]),
|
||||
self.model.scheduled_date < now
|
||||
)
|
||||
)
|
||||
.order_by(self.model.scheduled_date)
|
||||
.all()
|
||||
)
|
||||
|
||||
def generate_delivery_number(self, tenant_id: UUID) -> str:
|
||||
"""Generate next delivery number for tenant"""
|
||||
# Get current date
|
||||
today = datetime.utcnow()
|
||||
date_prefix = f"DEL{today.strftime('%Y%m%d')}"
|
||||
|
||||
# Find highest delivery number for today
|
||||
latest_delivery = (
|
||||
self.db.query(self.model.delivery_number)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.delivery_number.like(f"{date_prefix}%")
|
||||
)
|
||||
)
|
||||
.order_by(self.model.delivery_number.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_delivery:
|
||||
try:
|
||||
last_number = int(latest_delivery.delivery_number.replace(date_prefix, ""))
|
||||
new_number = last_number + 1
|
||||
except ValueError:
|
||||
new_number = 1
|
||||
else:
|
||||
new_number = 1
|
||||
|
||||
return f"{date_prefix}{new_number:03d}"
|
||||
|
||||
def update_delivery_status(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
status: DeliveryStatus,
|
||||
updated_by: UUID,
|
||||
notes: Optional[str] = None,
|
||||
update_timestamps: bool = True
|
||||
) -> Optional[Delivery]:
|
||||
"""Update delivery status with appropriate timestamps"""
|
||||
delivery = self.get_by_id(delivery_id)
|
||||
if not delivery:
|
||||
return None
|
||||
|
||||
old_status = delivery.status
|
||||
delivery.status = status
|
||||
delivery.created_by = updated_by # Track who updated
|
||||
|
||||
if update_timestamps:
|
||||
now = datetime.utcnow()
|
||||
|
||||
if status == DeliveryStatus.IN_TRANSIT and not delivery.estimated_arrival:
|
||||
# Set estimated arrival if not already set
|
||||
delivery.estimated_arrival = now + timedelta(hours=4) # Default 4 hours
|
||||
elif status == DeliveryStatus.DELIVERED:
|
||||
delivery.actual_arrival = now
|
||||
delivery.completed_at = now
|
||||
elif status == DeliveryStatus.PARTIALLY_DELIVERED:
|
||||
delivery.actual_arrival = now
|
||||
elif status == DeliveryStatus.FAILED_DELIVERY:
|
||||
delivery.actual_arrival = now
|
||||
|
||||
# Add status change note
|
||||
if notes:
|
||||
existing_notes = delivery.notes or ""
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
status_note = f"[{timestamp}] Status: {old_status.value} → {status.value}: {notes}"
|
||||
delivery.notes = f"{existing_notes}\n{status_note}".strip()
|
||||
|
||||
delivery.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(delivery)
|
||||
return delivery
|
||||
|
||||
def mark_as_received(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
received_by: UUID,
|
||||
inspection_passed: bool = True,
|
||||
inspection_notes: Optional[str] = None,
|
||||
quality_issues: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[Delivery]:
|
||||
"""Mark delivery as received with inspection details"""
|
||||
delivery = self.get_by_id(delivery_id)
|
||||
if not delivery:
|
||||
return None
|
||||
|
||||
delivery.status = DeliveryStatus.DELIVERED
|
||||
delivery.received_by = received_by
|
||||
delivery.received_at = datetime.utcnow()
|
||||
delivery.completed_at = datetime.utcnow()
|
||||
delivery.inspection_passed = inspection_passed
|
||||
|
||||
if inspection_notes:
|
||||
delivery.inspection_notes = inspection_notes
|
||||
|
||||
if quality_issues:
|
||||
delivery.quality_issues = quality_issues
|
||||
|
||||
if not delivery.actual_arrival:
|
||||
delivery.actual_arrival = datetime.utcnow()
|
||||
|
||||
delivery.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(delivery)
|
||||
return delivery
|
||||
|
||||
def get_delivery_performance_stats(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30,
|
||||
supplier_id: Optional[UUID] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get delivery performance statistics"""
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
|
||||
|
||||
query = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.created_at >= cutoff_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if supplier_id:
|
||||
query = query.filter(self.model.supplier_id == supplier_id)
|
||||
|
||||
deliveries = query.all()
|
||||
|
||||
if not deliveries:
|
||||
return {
|
||||
"total_deliveries": 0,
|
||||
"on_time_deliveries": 0,
|
||||
"late_deliveries": 0,
|
||||
"failed_deliveries": 0,
|
||||
"on_time_percentage": 0.0,
|
||||
"avg_delay_hours": 0.0,
|
||||
"quality_pass_rate": 0.0
|
||||
}
|
||||
|
||||
total_count = len(deliveries)
|
||||
on_time_count = 0
|
||||
late_count = 0
|
||||
failed_count = 0
|
||||
total_delay_hours = 0
|
||||
quality_pass_count = 0
|
||||
quality_total = 0
|
||||
|
||||
for delivery in deliveries:
|
||||
if delivery.status == DeliveryStatus.FAILED_DELIVERY:
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Check if delivery was on time
|
||||
if delivery.scheduled_date and delivery.actual_arrival:
|
||||
if delivery.actual_arrival <= delivery.scheduled_date:
|
||||
on_time_count += 1
|
||||
else:
|
||||
late_count += 1
|
||||
delay = delivery.actual_arrival - delivery.scheduled_date
|
||||
total_delay_hours += delay.total_seconds() / 3600
|
||||
|
||||
# Check quality inspection
|
||||
if delivery.inspection_passed is not None:
|
||||
quality_total += 1
|
||||
if delivery.inspection_passed:
|
||||
quality_pass_count += 1
|
||||
|
||||
on_time_percentage = (on_time_count / total_count * 100) if total_count > 0 else 0
|
||||
avg_delay_hours = total_delay_hours / late_count if late_count > 0 else 0
|
||||
quality_pass_rate = (quality_pass_count / quality_total * 100) if quality_total > 0 else 0
|
||||
|
||||
return {
|
||||
"total_deliveries": total_count,
|
||||
"on_time_deliveries": on_time_count,
|
||||
"late_deliveries": late_count,
|
||||
"failed_deliveries": failed_count,
|
||||
"on_time_percentage": round(on_time_percentage, 1),
|
||||
"avg_delay_hours": round(avg_delay_hours, 1),
|
||||
"quality_pass_rate": round(quality_pass_rate, 1)
|
||||
}
|
||||
|
||||
def get_upcoming_deliveries_summary(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get summary of upcoming deliveries"""
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
next_week = today + timedelta(days=7)
|
||||
|
||||
# Today's deliveries
|
||||
todays_deliveries = len(self.get_todays_deliveries(tenant_id))
|
||||
|
||||
# This week's deliveries
|
||||
this_week_deliveries = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.scheduled_date >= today,
|
||||
self.model.scheduled_date <= next_week,
|
||||
self.model.status.in_([
|
||||
DeliveryStatus.SCHEDULED,
|
||||
DeliveryStatus.IN_TRANSIT,
|
||||
DeliveryStatus.OUT_FOR_DELIVERY
|
||||
])
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Overdue deliveries
|
||||
overdue_count = len(self.get_overdue_deliveries(tenant_id))
|
||||
|
||||
# In transit deliveries
|
||||
in_transit_count = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == DeliveryStatus.IN_TRANSIT
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"todays_deliveries": todays_deliveries,
|
||||
"this_week_deliveries": this_week_deliveries,
|
||||
"overdue_deliveries": overdue_count,
|
||||
"in_transit_deliveries": in_transit_count
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
# services/suppliers/app/repositories/purchase_order_item_repository.py
|
||||
"""
|
||||
Purchase Order Item repository for database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.suppliers import PurchaseOrderItem
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class PurchaseOrderItemRepository(BaseRepository[PurchaseOrderItem]):
|
||||
"""Repository for purchase order item operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(PurchaseOrderItem, db)
|
||||
|
||||
def get_by_purchase_order(self, po_id: UUID) -> List[PurchaseOrderItem]:
|
||||
"""Get all items for a purchase order"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(self.model.purchase_order_id == po_id)
|
||||
.order_by(self.model.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_by_ingredient(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
limit: int = 20
|
||||
) -> List[PurchaseOrderItem]:
|
||||
"""Get recent order items for a specific ingredient"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.ingredient_id == ingredient_id
|
||||
)
|
||||
)
|
||||
.order_by(self.model.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update_received_quantity(
|
||||
self,
|
||||
item_id: UUID,
|
||||
received_quantity: int,
|
||||
update_remaining: bool = True
|
||||
) -> Optional[PurchaseOrderItem]:
|
||||
"""Update received quantity for an item"""
|
||||
item = self.get_by_id(item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
item.received_quantity = max(0, received_quantity)
|
||||
|
||||
if update_remaining:
|
||||
item.remaining_quantity = max(0, item.ordered_quantity - item.received_quantity)
|
||||
|
||||
item.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def add_received_quantity(
|
||||
self,
|
||||
item_id: UUID,
|
||||
quantity_to_add: int
|
||||
) -> Optional[PurchaseOrderItem]:
|
||||
"""Add to received quantity (for partial deliveries)"""
|
||||
item = self.get_by_id(item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
new_received = item.received_quantity + quantity_to_add
|
||||
new_received = min(new_received, item.ordered_quantity) # Cap at ordered quantity
|
||||
|
||||
return self.update_received_quantity(item_id, new_received)
|
||||
|
||||
def get_partially_received_items(self, tenant_id: UUID) -> List[PurchaseOrderItem]:
|
||||
"""Get items that are partially received (have remaining quantity)"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.received_quantity > 0,
|
||||
self.model.remaining_quantity > 0
|
||||
)
|
||||
)
|
||||
.order_by(self.model.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_pending_receipt_items(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: Optional[UUID] = None
|
||||
) -> List[PurchaseOrderItem]:
|
||||
"""Get items pending receipt (not yet delivered)"""
|
||||
query = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.remaining_quantity > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if ingredient_id:
|
||||
query = query.filter(self.model.ingredient_id == ingredient_id)
|
||||
|
||||
return query.order_by(self.model.created_at).all()
|
||||
|
||||
def calculate_line_total(self, item_id: UUID) -> Optional[PurchaseOrderItem]:
|
||||
"""Recalculate line total for an item"""
|
||||
item = self.get_by_id(item_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
item.line_total = item.ordered_quantity * item.unit_price
|
||||
item.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def get_ingredient_purchase_history(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
days_back: int = 90
|
||||
) -> Dict[str, Any]:
|
||||
"""Get purchase history and analytics for an ingredient"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
|
||||
|
||||
# Get items within date range
|
||||
items = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.ingredient_id == ingredient_id,
|
||||
self.model.created_at >= cutoff_date
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not items:
|
||||
return {
|
||||
"total_quantity_ordered": 0,
|
||||
"total_amount_spent": 0.0,
|
||||
"average_unit_price": 0.0,
|
||||
"order_count": 0,
|
||||
"last_order_date": None,
|
||||
"price_trend": "stable"
|
||||
}
|
||||
|
||||
# Calculate statistics
|
||||
total_quantity = sum(item.ordered_quantity for item in items)
|
||||
total_amount = sum(float(item.line_total) for item in items)
|
||||
order_count = len(items)
|
||||
avg_unit_price = total_amount / total_quantity if total_quantity > 0 else 0
|
||||
last_order_date = max(item.created_at for item in items)
|
||||
|
||||
# Price trend analysis (simple)
|
||||
if order_count >= 2:
|
||||
sorted_items = sorted(items, key=lambda x: x.created_at)
|
||||
first_half = sorted_items[:order_count//2]
|
||||
second_half = sorted_items[order_count//2:]
|
||||
|
||||
avg_price_first = sum(float(item.unit_price) for item in first_half) / len(first_half)
|
||||
avg_price_second = sum(float(item.unit_price) for item in second_half) / len(second_half)
|
||||
|
||||
if avg_price_second > avg_price_first * 1.1:
|
||||
price_trend = "increasing"
|
||||
elif avg_price_second < avg_price_first * 0.9:
|
||||
price_trend = "decreasing"
|
||||
else:
|
||||
price_trend = "stable"
|
||||
else:
|
||||
price_trend = "insufficient_data"
|
||||
|
||||
return {
|
||||
"total_quantity_ordered": total_quantity,
|
||||
"total_amount_spent": round(total_amount, 2),
|
||||
"average_unit_price": round(avg_unit_price, 4),
|
||||
"order_count": order_count,
|
||||
"last_order_date": last_order_date,
|
||||
"price_trend": price_trend
|
||||
}
|
||||
|
||||
def get_top_purchased_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get most purchased ingredients by quantity or value"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
|
||||
|
||||
# Group by ingredient and calculate totals
|
||||
results = (
|
||||
self.db.query(
|
||||
self.model.ingredient_id,
|
||||
self.model.product_name,
|
||||
self.model.unit_of_measure,
|
||||
func.sum(self.model.ordered_quantity).label('total_quantity'),
|
||||
func.sum(self.model.line_total).label('total_amount'),
|
||||
func.count(self.model.id).label('order_count'),
|
||||
func.avg(self.model.unit_price).label('avg_unit_price')
|
||||
)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.created_at >= cutoff_date
|
||||
)
|
||||
)
|
||||
.group_by(
|
||||
self.model.ingredient_id,
|
||||
self.model.product_name,
|
||||
self.model.unit_of_measure
|
||||
)
|
||||
.order_by(func.sum(self.model.line_total).desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"ingredient_id": str(row.ingredient_id),
|
||||
"product_name": row.product_name,
|
||||
"unit_of_measure": row.unit_of_measure,
|
||||
"total_quantity": int(row.total_quantity),
|
||||
"total_amount": round(float(row.total_amount), 2),
|
||||
"order_count": int(row.order_count),
|
||||
"avg_unit_price": round(float(row.avg_unit_price), 4)
|
||||
}
|
||||
for row in results
|
||||
]
|
||||
|
||||
def bulk_update_items(
|
||||
self,
|
||||
po_id: UUID,
|
||||
item_updates: List[Dict[str, Any]]
|
||||
) -> List[PurchaseOrderItem]:
|
||||
"""Bulk update multiple items in a purchase order"""
|
||||
updated_items = []
|
||||
|
||||
for update_data in item_updates:
|
||||
item_id = update_data.get('id')
|
||||
if not item_id:
|
||||
continue
|
||||
|
||||
item = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.id == item_id,
|
||||
self.model.purchase_order_id == po_id
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if item:
|
||||
# Update allowed fields
|
||||
for key, value in update_data.items():
|
||||
if key != 'id' and hasattr(item, key):
|
||||
setattr(item, key, value)
|
||||
|
||||
# Recalculate line total if quantity or price changed
|
||||
if 'ordered_quantity' in update_data or 'unit_price' in update_data:
|
||||
item.line_total = item.ordered_quantity * item.unit_price
|
||||
item.remaining_quantity = item.ordered_quantity - item.received_quantity
|
||||
|
||||
item.updated_at = datetime.utcnow()
|
||||
updated_items.append(item)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
# Refresh all items
|
||||
for item in updated_items:
|
||||
self.db.refresh(item)
|
||||
|
||||
return updated_items
|
||||
376
services/suppliers/app/repositories/purchase_order_repository.py
Normal file
376
services/suppliers/app/repositories/purchase_order_repository.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# services/suppliers/app/repositories/purchase_order_repository.py
|
||||
"""
|
||||
Purchase Order repository for database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.suppliers import (
|
||||
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus,
|
||||
Supplier, SupplierPriceList
|
||||
)
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class PurchaseOrderRepository(BaseRepository[PurchaseOrder]):
|
||||
"""Repository for purchase order management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(PurchaseOrder, db)
|
||||
|
||||
def get_by_po_number(self, tenant_id: UUID, po_number: str) -> Optional[PurchaseOrder]:
|
||||
"""Get purchase order by PO number within tenant"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.po_number == po_number
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_with_items(self, po_id: UUID) -> Optional[PurchaseOrder]:
|
||||
"""Get purchase order with all items loaded"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.items))
|
||||
.filter(self.model.id == po_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def search_purchase_orders(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_id: Optional[UUID] = None,
|
||||
status: Optional[PurchaseOrderStatus] = None,
|
||||
priority: Optional[str] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None,
|
||||
search_term: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Search purchase orders with comprehensive filters"""
|
||||
query = (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.supplier))
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
)
|
||||
|
||||
# Supplier filter
|
||||
if supplier_id:
|
||||
query = query.filter(self.model.supplier_id == supplier_id)
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(self.model.status == status)
|
||||
|
||||
# Priority filter
|
||||
if priority:
|
||||
query = query.filter(self.model.priority == priority)
|
||||
|
||||
# Date range filter
|
||||
if date_from:
|
||||
query = query.filter(self.model.order_date >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(self.model.order_date <= date_to)
|
||||
|
||||
# Search term filter (PO number, reference, supplier name)
|
||||
if search_term:
|
||||
search_filter = or_(
|
||||
self.model.po_number.ilike(f"%{search_term}%"),
|
||||
self.model.reference_number.ilike(f"%{search_term}%"),
|
||||
self.model.supplier.has(Supplier.name.ilike(f"%{search_term}%"))
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
return (
|
||||
query.order_by(desc(self.model.order_date))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_orders_by_status(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
status: PurchaseOrderStatus
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Get all orders with specific status"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.supplier))
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == status
|
||||
)
|
||||
)
|
||||
.order_by(self.model.order_date.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_orders_requiring_approval(self, tenant_id: UUID) -> List[PurchaseOrder]:
|
||||
"""Get orders pending approval"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.supplier))
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == PurchaseOrderStatus.PENDING_APPROVAL,
|
||||
self.model.requires_approval == True
|
||||
)
|
||||
)
|
||||
.order_by(self.model.order_date.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_overdue_orders(self, tenant_id: UUID) -> List[PurchaseOrder]:
|
||||
"""Get orders that are overdue for delivery"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.supplier))
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status.in_([
|
||||
PurchaseOrderStatus.CONFIRMED,
|
||||
PurchaseOrderStatus.SENT_TO_SUPPLIER,
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED
|
||||
]),
|
||||
self.model.required_delivery_date < today
|
||||
)
|
||||
)
|
||||
.order_by(self.model.required_delivery_date.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_orders_by_supplier(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_id: UUID,
|
||||
limit: int = 20
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Get recent orders for a specific supplier"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.supplier_id == supplier_id
|
||||
)
|
||||
)
|
||||
.order_by(self.model.order_date.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def generate_po_number(self, tenant_id: UUID) -> str:
|
||||
"""Generate next PO number for tenant"""
|
||||
# Get current year
|
||||
current_year = datetime.utcnow().year
|
||||
|
||||
# Find highest PO number for current year
|
||||
year_prefix = f"PO{current_year}"
|
||||
|
||||
latest_po = (
|
||||
self.db.query(self.model.po_number)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.po_number.like(f"{year_prefix}%")
|
||||
)
|
||||
)
|
||||
.order_by(self.model.po_number.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if latest_po:
|
||||
# Extract number and increment
|
||||
try:
|
||||
last_number = int(latest_po.po_number.replace(year_prefix, ""))
|
||||
new_number = last_number + 1
|
||||
except ValueError:
|
||||
new_number = 1
|
||||
else:
|
||||
new_number = 1
|
||||
|
||||
return f"{year_prefix}{new_number:04d}"
|
||||
|
||||
def update_order_status(
|
||||
self,
|
||||
po_id: UUID,
|
||||
status: PurchaseOrderStatus,
|
||||
updated_by: UUID,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Update purchase order status with audit trail"""
|
||||
po = self.get_by_id(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
po.status = status
|
||||
po.updated_by = updated_by
|
||||
po.updated_at = datetime.utcnow()
|
||||
|
||||
if notes:
|
||||
existing_notes = po.internal_notes or ""
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
po.internal_notes = f"{existing_notes}\n[{timestamp}] Status changed to {status.value}: {notes}".strip()
|
||||
|
||||
# Set specific timestamps based on status
|
||||
if status == PurchaseOrderStatus.APPROVED:
|
||||
po.approved_at = datetime.utcnow()
|
||||
elif status == PurchaseOrderStatus.SENT_TO_SUPPLIER:
|
||||
po.sent_to_supplier_at = datetime.utcnow()
|
||||
elif status == PurchaseOrderStatus.CONFIRMED:
|
||||
po.supplier_confirmation_date = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(po)
|
||||
return po
|
||||
|
||||
def approve_order(
|
||||
self,
|
||||
po_id: UUID,
|
||||
approved_by: UUID,
|
||||
approval_notes: Optional[str] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Approve a purchase order"""
|
||||
po = self.get_by_id(po_id)
|
||||
if not po or po.status != PurchaseOrderStatus.PENDING_APPROVAL:
|
||||
return None
|
||||
|
||||
po.status = PurchaseOrderStatus.APPROVED
|
||||
po.approved_by = approved_by
|
||||
po.approved_at = datetime.utcnow()
|
||||
po.updated_by = approved_by
|
||||
po.updated_at = datetime.utcnow()
|
||||
|
||||
if approval_notes:
|
||||
po.internal_notes = (po.internal_notes or "") + f"\nApproval notes: {approval_notes}"
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(po)
|
||||
return po
|
||||
|
||||
def calculate_order_totals(self, po_id: UUID) -> Optional[PurchaseOrder]:
|
||||
"""Recalculate order totals based on line items"""
|
||||
po = self.get_with_items(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
# Calculate subtotal from items
|
||||
subtotal = sum(item.line_total for item in po.items)
|
||||
|
||||
# Keep existing tax, shipping, and discount
|
||||
tax_amount = po.tax_amount or 0
|
||||
shipping_cost = po.shipping_cost or 0
|
||||
discount_amount = po.discount_amount or 0
|
||||
|
||||
# Calculate total
|
||||
total_amount = subtotal + tax_amount + shipping_cost - discount_amount
|
||||
|
||||
# Update PO
|
||||
po.subtotal = subtotal
|
||||
po.total_amount = max(0, total_amount) # Ensure non-negative
|
||||
po.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(po)
|
||||
return po
|
||||
|
||||
def get_purchase_order_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get purchase order statistics for dashboard"""
|
||||
# Total orders
|
||||
total_orders = self.count_by_tenant(tenant_id)
|
||||
|
||||
# Orders by status
|
||||
status_counts = (
|
||||
self.db.query(
|
||||
self.model.status,
|
||||
func.count(self.model.id).label('count')
|
||||
)
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
.group_by(self.model.status)
|
||||
.all()
|
||||
)
|
||||
|
||||
status_dict = {status.value: 0 for status in PurchaseOrderStatus}
|
||||
for status, count in status_counts:
|
||||
status_dict[status.value] = count
|
||||
|
||||
# This month's orders
|
||||
first_day_month = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
this_month_orders = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.order_date >= first_day_month
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
# Total spend this month
|
||||
this_month_spend = (
|
||||
self.db.query(func.sum(self.model.total_amount))
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.order_date >= first_day_month,
|
||||
self.model.status != PurchaseOrderStatus.CANCELLED
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
) or 0.0
|
||||
|
||||
# Average order value
|
||||
avg_order_value = (
|
||||
self.db.query(func.avg(self.model.total_amount))
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status != PurchaseOrderStatus.CANCELLED
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
) or 0.0
|
||||
|
||||
# Overdue orders count
|
||||
today = datetime.utcnow().date()
|
||||
overdue_count = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status.in_([
|
||||
PurchaseOrderStatus.CONFIRMED,
|
||||
PurchaseOrderStatus.SENT_TO_SUPPLIER,
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED
|
||||
]),
|
||||
self.model.required_delivery_date < today
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"total_orders": total_orders,
|
||||
"status_counts": status_dict,
|
||||
"this_month_orders": this_month_orders,
|
||||
"this_month_spend": float(this_month_spend),
|
||||
"avg_order_value": round(float(avg_order_value), 2),
|
||||
"overdue_count": overdue_count,
|
||||
"pending_approval": status_dict.get(PurchaseOrderStatus.PENDING_APPROVAL.value, 0)
|
||||
}
|
||||
298
services/suppliers/app/repositories/supplier_repository.py
Normal file
298
services/suppliers/app/repositories/supplier_repository.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# services/suppliers/app/repositories/supplier_repository.py
|
||||
"""
|
||||
Supplier repository for database operations
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.suppliers import Supplier, SupplierStatus, SupplierType
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class SupplierRepository(BaseRepository[Supplier]):
|
||||
"""Repository for supplier management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
super().__init__(Supplier, db)
|
||||
|
||||
def get_by_name(self, tenant_id: UUID, name: str) -> Optional[Supplier]:
|
||||
"""Get supplier by name within tenant"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.name == name
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_by_supplier_code(self, tenant_id: UUID, supplier_code: str) -> Optional[Supplier]:
|
||||
"""Get supplier by supplier code within tenant"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.supplier_code == supplier_code
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def search_suppliers(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_term: Optional[str] = None,
|
||||
supplier_type: Optional[SupplierType] = None,
|
||||
status: Optional[SupplierStatus] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Supplier]:
|
||||
"""Search suppliers with filters"""
|
||||
query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id)
|
||||
|
||||
# Search term filter (name, contact person, email)
|
||||
if search_term:
|
||||
search_filter = or_(
|
||||
self.model.name.ilike(f"%{search_term}%"),
|
||||
self.model.contact_person.ilike(f"%{search_term}%"),
|
||||
self.model.email.ilike(f"%{search_term}%")
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
# Type filter
|
||||
if supplier_type:
|
||||
query = query.filter(self.model.supplier_type == supplier_type)
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(self.model.status == status)
|
||||
|
||||
return query.order_by(self.model.name).limit(limit).offset(offset).all()
|
||||
|
||||
def get_active_suppliers(self, tenant_id: UUID) -> List[Supplier]:
|
||||
"""Get all active suppliers for a tenant"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == SupplierStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.order_by(self.model.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_suppliers_by_type(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_type: SupplierType
|
||||
) -> List[Supplier]:
|
||||
"""Get suppliers by type"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.supplier_type == supplier_type,
|
||||
self.model.status == SupplierStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.order_by(self.model.quality_rating.desc(), self.model.name)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_top_suppliers(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
limit: int = 10
|
||||
) -> List[Supplier]:
|
||||
"""Get top suppliers by quality rating and order value"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == SupplierStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
self.model.quality_rating.desc(),
|
||||
self.model.total_amount.desc()
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def update_supplier_stats(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
total_orders_increment: int = 0,
|
||||
total_amount_increment: float = 0.0,
|
||||
new_quality_rating: Optional[float] = None,
|
||||
new_delivery_rating: Optional[float] = None
|
||||
) -> Optional[Supplier]:
|
||||
"""Update supplier performance statistics"""
|
||||
supplier = self.get_by_id(supplier_id)
|
||||
if not supplier:
|
||||
return None
|
||||
|
||||
# Update counters
|
||||
if total_orders_increment:
|
||||
supplier.total_orders += total_orders_increment
|
||||
|
||||
if total_amount_increment:
|
||||
supplier.total_amount += total_amount_increment
|
||||
|
||||
# Update ratings (these should be calculated averages)
|
||||
if new_quality_rating is not None:
|
||||
supplier.quality_rating = new_quality_rating
|
||||
|
||||
if new_delivery_rating is not None:
|
||||
supplier.delivery_rating = new_delivery_rating
|
||||
|
||||
supplier.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(supplier)
|
||||
return supplier
|
||||
|
||||
def get_suppliers_needing_review(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_since_last_order: int = 30
|
||||
) -> List[Supplier]:
|
||||
"""Get suppliers that may need performance review"""
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days_since_last_order)
|
||||
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == SupplierStatus.ACTIVE,
|
||||
or_(
|
||||
self.model.quality_rating < 3.0, # Poor rating
|
||||
self.model.delivery_rating < 3.0, # Poor delivery
|
||||
self.model.updated_at < cutoff_date # Long time since interaction
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(self.model.quality_rating.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_supplier_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get supplier statistics for dashboard"""
|
||||
total_suppliers = self.count_by_tenant(tenant_id)
|
||||
|
||||
active_suppliers = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == SupplierStatus.ACTIVE
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
pending_suppliers = (
|
||||
self.db.query(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == SupplierStatus.PENDING_APPROVAL
|
||||
)
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
avg_quality_rating = (
|
||||
self.db.query(func.avg(self.model.quality_rating))
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == SupplierStatus.ACTIVE,
|
||||
self.model.quality_rating > 0
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
) or 0.0
|
||||
|
||||
avg_delivery_rating = (
|
||||
self.db.query(func.avg(self.model.delivery_rating))
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == SupplierStatus.ACTIVE,
|
||||
self.model.delivery_rating > 0
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
) or 0.0
|
||||
|
||||
total_spend = (
|
||||
self.db.query(func.sum(self.model.total_amount))
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
.scalar()
|
||||
) or 0.0
|
||||
|
||||
return {
|
||||
"total_suppliers": total_suppliers,
|
||||
"active_suppliers": active_suppliers,
|
||||
"pending_suppliers": pending_suppliers,
|
||||
"avg_quality_rating": round(float(avg_quality_rating), 2),
|
||||
"avg_delivery_rating": round(float(avg_delivery_rating), 2),
|
||||
"total_spend": float(total_spend)
|
||||
}
|
||||
|
||||
def approve_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
approved_by: UUID,
|
||||
approval_date: Optional[datetime] = None
|
||||
) -> Optional[Supplier]:
|
||||
"""Approve a pending supplier"""
|
||||
supplier = self.get_by_id(supplier_id)
|
||||
if not supplier or supplier.status != SupplierStatus.PENDING_APPROVAL:
|
||||
return None
|
||||
|
||||
supplier.status = SupplierStatus.ACTIVE
|
||||
supplier.approved_by = approved_by
|
||||
supplier.approved_at = approval_date or datetime.utcnow()
|
||||
supplier.rejection_reason = None
|
||||
supplier.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(supplier)
|
||||
return supplier
|
||||
|
||||
def reject_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
rejection_reason: str,
|
||||
approved_by: UUID
|
||||
) -> Optional[Supplier]:
|
||||
"""Reject a pending supplier"""
|
||||
supplier = self.get_by_id(supplier_id)
|
||||
if not supplier or supplier.status != SupplierStatus.PENDING_APPROVAL:
|
||||
return None
|
||||
|
||||
supplier.status = SupplierStatus.INACTIVE
|
||||
supplier.rejection_reason = rejection_reason
|
||||
supplier.approved_by = approved_by
|
||||
supplier.approved_at = datetime.utcnow()
|
||||
supplier.updated_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(supplier)
|
||||
return supplier
|
||||
1
services/suppliers/app/schemas/__init__.py
Normal file
1
services/suppliers/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/schemas/__init__.py
|
||||
627
services/suppliers/app/schemas/suppliers.py
Normal file
627
services/suppliers/app/schemas/suppliers.py
Normal file
@@ -0,0 +1,627 @@
|
||||
# services/suppliers/app/schemas/suppliers.py
|
||||
"""
|
||||
Pydantic schemas for supplier-related API requests and responses
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.suppliers import (
|
||||
SupplierType, SupplierStatus, PaymentTerms,
|
||||
PurchaseOrderStatus, DeliveryStatus,
|
||||
QualityRating, DeliveryRating, InvoiceStatus
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SUPPLIER SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class SupplierCreate(BaseModel):
|
||||
"""Schema for creating suppliers"""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
supplier_code: Optional[str] = Field(None, max_length=50)
|
||||
tax_id: Optional[str] = Field(None, max_length=50)
|
||||
registration_number: Optional[str] = Field(None, max_length=100)
|
||||
supplier_type: SupplierType
|
||||
contact_person: Optional[str] = Field(None, max_length=200)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=30)
|
||||
mobile: Optional[str] = Field(None, max_length=30)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
# Address
|
||||
address_line1: Optional[str] = Field(None, max_length=255)
|
||||
address_line2: Optional[str] = Field(None, max_length=255)
|
||||
city: Optional[str] = Field(None, max_length=100)
|
||||
state_province: Optional[str] = Field(None, max_length=100)
|
||||
postal_code: Optional[str] = Field(None, max_length=20)
|
||||
country: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
# Business terms
|
||||
payment_terms: PaymentTerms = PaymentTerms.NET_30
|
||||
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
||||
currency: str = Field(default="EUR", max_length=3)
|
||||
standard_lead_time: int = Field(default=3, ge=0, le=365)
|
||||
minimum_order_amount: Optional[Decimal] = Field(None, ge=0)
|
||||
delivery_area: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
certifications: Optional[Dict[str, Any]] = None
|
||||
business_hours: Optional[Dict[str, Any]] = None
|
||||
specializations: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SupplierUpdate(BaseModel):
|
||||
"""Schema for updating suppliers"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
supplier_code: Optional[str] = Field(None, max_length=50)
|
||||
tax_id: Optional[str] = Field(None, max_length=50)
|
||||
registration_number: Optional[str] = Field(None, max_length=100)
|
||||
supplier_type: Optional[SupplierType] = None
|
||||
status: Optional[SupplierStatus] = None
|
||||
contact_person: Optional[str] = Field(None, max_length=200)
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = Field(None, max_length=30)
|
||||
mobile: Optional[str] = Field(None, max_length=30)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
# Address
|
||||
address_line1: Optional[str] = Field(None, max_length=255)
|
||||
address_line2: Optional[str] = Field(None, max_length=255)
|
||||
city: Optional[str] = Field(None, max_length=100)
|
||||
state_province: Optional[str] = Field(None, max_length=100)
|
||||
postal_code: Optional[str] = Field(None, max_length=20)
|
||||
country: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
# Business terms
|
||||
payment_terms: Optional[PaymentTerms] = None
|
||||
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
||||
currency: Optional[str] = Field(None, max_length=3)
|
||||
standard_lead_time: Optional[int] = Field(None, ge=0, le=365)
|
||||
minimum_order_amount: Optional[Decimal] = Field(None, ge=0)
|
||||
delivery_area: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
certifications: Optional[Dict[str, Any]] = None
|
||||
business_hours: Optional[Dict[str, Any]] = None
|
||||
specializations: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class SupplierApproval(BaseModel):
|
||||
"""Schema for supplier approval/rejection"""
|
||||
action: str = Field(..., regex="^(approve|reject)$")
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SupplierResponse(BaseModel):
|
||||
"""Schema for supplier responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
name: str
|
||||
supplier_code: Optional[str] = None
|
||||
tax_id: Optional[str] = None
|
||||
registration_number: Optional[str] = None
|
||||
supplier_type: SupplierType
|
||||
status: SupplierStatus
|
||||
contact_person: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
|
||||
# Address
|
||||
address_line1: Optional[str] = None
|
||||
address_line2: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state_province: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
|
||||
# Business terms
|
||||
payment_terms: PaymentTerms
|
||||
credit_limit: Optional[Decimal] = None
|
||||
currency: str
|
||||
standard_lead_time: int
|
||||
minimum_order_amount: Optional[Decimal] = None
|
||||
delivery_area: Optional[str] = None
|
||||
|
||||
# Performance metrics
|
||||
quality_rating: Optional[float] = None
|
||||
delivery_rating: Optional[float] = None
|
||||
total_orders: int
|
||||
total_amount: Decimal
|
||||
|
||||
# Approval info
|
||||
approved_by: Optional[UUID] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
certifications: Optional[Dict[str, Any]] = None
|
||||
business_hours: Optional[Dict[str, Any]] = None
|
||||
specializations: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Audit fields
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: UUID
|
||||
updated_by: UUID
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SupplierSummary(BaseModel):
|
||||
"""Schema for supplier summary (list view)"""
|
||||
id: UUID
|
||||
name: str
|
||||
supplier_code: Optional[str] = None
|
||||
supplier_type: SupplierType
|
||||
status: SupplierStatus
|
||||
contact_person: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
quality_rating: Optional[float] = None
|
||||
delivery_rating: Optional[float] = None
|
||||
total_orders: int
|
||||
total_amount: Decimal
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PURCHASE ORDER SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class PurchaseOrderItemCreate(BaseModel):
|
||||
"""Schema for creating purchase order items"""
|
||||
ingredient_id: UUID
|
||||
product_code: Optional[str] = Field(None, max_length=100)
|
||||
product_name: str = Field(..., min_length=1, max_length=255)
|
||||
ordered_quantity: int = Field(..., gt=0)
|
||||
unit_of_measure: str = Field(..., max_length=20)
|
||||
unit_price: Decimal = Field(..., gt=0)
|
||||
quality_requirements: Optional[str] = None
|
||||
item_notes: Optional[str] = None
|
||||
|
||||
|
||||
class PurchaseOrderItemUpdate(BaseModel):
|
||||
"""Schema for updating purchase order items"""
|
||||
ordered_quantity: Optional[int] = Field(None, gt=0)
|
||||
unit_price: Optional[Decimal] = Field(None, gt=0)
|
||||
quality_requirements: Optional[str] = None
|
||||
item_notes: Optional[str] = None
|
||||
|
||||
|
||||
class PurchaseOrderItemResponse(BaseModel):
|
||||
"""Schema for purchase order item responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
purchase_order_id: UUID
|
||||
price_list_item_id: Optional[UUID] = None
|
||||
ingredient_id: UUID
|
||||
product_code: Optional[str] = None
|
||||
product_name: str
|
||||
ordered_quantity: int
|
||||
unit_of_measure: str
|
||||
unit_price: Decimal
|
||||
line_total: Decimal
|
||||
received_quantity: int
|
||||
remaining_quantity: int
|
||||
quality_requirements: Optional[str] = None
|
||||
item_notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PurchaseOrderCreate(BaseModel):
|
||||
"""Schema for creating purchase orders"""
|
||||
supplier_id: UUID
|
||||
reference_number: Optional[str] = Field(None, max_length=100)
|
||||
priority: str = Field(default="normal", max_length=20)
|
||||
required_delivery_date: Optional[datetime] = None
|
||||
|
||||
# Delivery information
|
||||
delivery_address: Optional[str] = None
|
||||
delivery_instructions: Optional[str] = None
|
||||
delivery_contact: Optional[str] = Field(None, max_length=200)
|
||||
delivery_phone: Optional[str] = Field(None, max_length=30)
|
||||
|
||||
# Financial information
|
||||
tax_amount: Decimal = Field(default=0, ge=0)
|
||||
shipping_cost: Decimal = Field(default=0, ge=0)
|
||||
discount_amount: Decimal = Field(default=0, ge=0)
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
internal_notes: Optional[str] = None
|
||||
terms_and_conditions: Optional[str] = None
|
||||
|
||||
# Items
|
||||
items: List[PurchaseOrderItemCreate] = Field(..., min_items=1)
|
||||
|
||||
|
||||
class PurchaseOrderUpdate(BaseModel):
|
||||
"""Schema for updating purchase orders"""
|
||||
reference_number: Optional[str] = Field(None, max_length=100)
|
||||
priority: Optional[str] = Field(None, max_length=20)
|
||||
required_delivery_date: Optional[datetime] = None
|
||||
estimated_delivery_date: Optional[datetime] = None
|
||||
|
||||
# Delivery information
|
||||
delivery_address: Optional[str] = None
|
||||
delivery_instructions: Optional[str] = None
|
||||
delivery_contact: Optional[str] = Field(None, max_length=200)
|
||||
delivery_phone: Optional[str] = Field(None, max_length=30)
|
||||
|
||||
# Financial information
|
||||
tax_amount: Optional[Decimal] = Field(None, ge=0)
|
||||
shipping_cost: Optional[Decimal] = Field(None, ge=0)
|
||||
discount_amount: Optional[Decimal] = Field(None, ge=0)
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
internal_notes: Optional[str] = None
|
||||
terms_and_conditions: Optional[str] = None
|
||||
|
||||
# Supplier communication
|
||||
supplier_reference: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class PurchaseOrderStatusUpdate(BaseModel):
|
||||
"""Schema for updating purchase order status"""
|
||||
status: PurchaseOrderStatus
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PurchaseOrderApproval(BaseModel):
|
||||
"""Schema for purchase order approval/rejection"""
|
||||
action: str = Field(..., regex="^(approve|reject)$")
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PurchaseOrderResponse(BaseModel):
|
||||
"""Schema for purchase order responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
supplier_id: UUID
|
||||
po_number: str
|
||||
reference_number: Optional[str] = None
|
||||
status: PurchaseOrderStatus
|
||||
priority: str
|
||||
order_date: datetime
|
||||
required_delivery_date: Optional[datetime] = None
|
||||
estimated_delivery_date: Optional[datetime] = None
|
||||
|
||||
# Financial information
|
||||
subtotal: Decimal
|
||||
tax_amount: Decimal
|
||||
shipping_cost: Decimal
|
||||
discount_amount: Decimal
|
||||
total_amount: Decimal
|
||||
currency: str
|
||||
|
||||
# Delivery information
|
||||
delivery_address: Optional[str] = None
|
||||
delivery_instructions: Optional[str] = None
|
||||
delivery_contact: Optional[str] = None
|
||||
delivery_phone: Optional[str] = None
|
||||
|
||||
# Approval workflow
|
||||
requires_approval: bool
|
||||
approved_by: Optional[UUID] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
# Communication tracking
|
||||
sent_to_supplier_at: Optional[datetime] = None
|
||||
supplier_confirmation_date: Optional[datetime] = None
|
||||
supplier_reference: Optional[str] = None
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
internal_notes: Optional[str] = None
|
||||
terms_and_conditions: Optional[str] = None
|
||||
|
||||
# Audit fields
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: UUID
|
||||
updated_by: UUID
|
||||
|
||||
# Related data (populated separately)
|
||||
supplier: Optional[SupplierSummary] = None
|
||||
items: Optional[List[PurchaseOrderItemResponse]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PurchaseOrderSummary(BaseModel):
|
||||
"""Schema for purchase order summary (list view)"""
|
||||
id: UUID
|
||||
po_number: str
|
||||
supplier_id: UUID
|
||||
supplier_name: Optional[str] = None
|
||||
status: PurchaseOrderStatus
|
||||
priority: str
|
||||
order_date: datetime
|
||||
required_delivery_date: Optional[datetime] = None
|
||||
total_amount: Decimal
|
||||
currency: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DELIVERY SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class DeliveryItemCreate(BaseModel):
|
||||
"""Schema for creating delivery items"""
|
||||
purchase_order_item_id: UUID
|
||||
ingredient_id: UUID
|
||||
product_name: str = Field(..., min_length=1, max_length=255)
|
||||
ordered_quantity: int = Field(..., gt=0)
|
||||
delivered_quantity: int = Field(..., ge=0)
|
||||
accepted_quantity: int = Field(..., ge=0)
|
||||
rejected_quantity: int = Field(default=0, ge=0)
|
||||
|
||||
# Quality information
|
||||
batch_lot_number: Optional[str] = Field(None, max_length=100)
|
||||
expiry_date: Optional[datetime] = None
|
||||
quality_grade: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
# Issues and notes
|
||||
quality_issues: Optional[str] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
item_notes: Optional[str] = None
|
||||
|
||||
|
||||
class DeliveryItemResponse(BaseModel):
|
||||
"""Schema for delivery item responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
delivery_id: UUID
|
||||
purchase_order_item_id: UUID
|
||||
ingredient_id: UUID
|
||||
product_name: str
|
||||
ordered_quantity: int
|
||||
delivered_quantity: int
|
||||
accepted_quantity: int
|
||||
rejected_quantity: int
|
||||
batch_lot_number: Optional[str] = None
|
||||
expiry_date: Optional[datetime] = None
|
||||
quality_grade: Optional[str] = None
|
||||
quality_issues: Optional[str] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
item_notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DeliveryCreate(BaseModel):
|
||||
"""Schema for creating deliveries"""
|
||||
purchase_order_id: UUID
|
||||
supplier_id: UUID
|
||||
supplier_delivery_note: Optional[str] = Field(None, max_length=100)
|
||||
scheduled_date: Optional[datetime] = None
|
||||
estimated_arrival: Optional[datetime] = None
|
||||
|
||||
# Delivery details
|
||||
delivery_address: Optional[str] = None
|
||||
delivery_contact: Optional[str] = Field(None, max_length=200)
|
||||
delivery_phone: Optional[str] = Field(None, max_length=30)
|
||||
carrier_name: Optional[str] = Field(None, max_length=200)
|
||||
tracking_number: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
|
||||
# Items
|
||||
items: List[DeliveryItemCreate] = Field(..., min_items=1)
|
||||
|
||||
|
||||
class DeliveryUpdate(BaseModel):
|
||||
"""Schema for updating deliveries"""
|
||||
supplier_delivery_note: Optional[str] = Field(None, max_length=100)
|
||||
scheduled_date: Optional[datetime] = None
|
||||
estimated_arrival: Optional[datetime] = None
|
||||
actual_arrival: Optional[datetime] = None
|
||||
|
||||
# Delivery details
|
||||
delivery_address: Optional[str] = None
|
||||
delivery_contact: Optional[str] = Field(None, max_length=200)
|
||||
delivery_phone: Optional[str] = Field(None, max_length=30)
|
||||
carrier_name: Optional[str] = Field(None, max_length=200)
|
||||
tracking_number: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
# Quality inspection
|
||||
inspection_passed: Optional[bool] = None
|
||||
inspection_notes: Optional[str] = None
|
||||
quality_issues: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class DeliveryStatusUpdate(BaseModel):
|
||||
"""Schema for updating delivery status"""
|
||||
status: DeliveryStatus
|
||||
notes: Optional[str] = None
|
||||
update_timestamps: bool = Field(default=True)
|
||||
|
||||
|
||||
class DeliveryReceiptConfirmation(BaseModel):
|
||||
"""Schema for confirming delivery receipt"""
|
||||
inspection_passed: bool = True
|
||||
inspection_notes: Optional[str] = None
|
||||
quality_issues: Optional[Dict[str, Any]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class DeliveryResponse(BaseModel):
|
||||
"""Schema for delivery responses"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
purchase_order_id: UUID
|
||||
supplier_id: UUID
|
||||
delivery_number: str
|
||||
supplier_delivery_note: Optional[str] = None
|
||||
status: DeliveryStatus
|
||||
|
||||
# Timing
|
||||
scheduled_date: Optional[datetime] = None
|
||||
estimated_arrival: Optional[datetime] = None
|
||||
actual_arrival: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
# Delivery details
|
||||
delivery_address: Optional[str] = None
|
||||
delivery_contact: Optional[str] = None
|
||||
delivery_phone: Optional[str] = None
|
||||
carrier_name: Optional[str] = None
|
||||
tracking_number: Optional[str] = None
|
||||
|
||||
# Quality inspection
|
||||
inspection_passed: Optional[bool] = None
|
||||
inspection_notes: Optional[str] = None
|
||||
quality_issues: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Receipt information
|
||||
received_by: Optional[UUID] = None
|
||||
received_at: Optional[datetime] = None
|
||||
|
||||
# Additional information
|
||||
notes: Optional[str] = None
|
||||
photos: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Audit fields
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: UUID
|
||||
|
||||
# Related data
|
||||
supplier: Optional[SupplierSummary] = None
|
||||
purchase_order: Optional[PurchaseOrderSummary] = None
|
||||
items: Optional[List[DeliveryItemResponse]] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DeliverySummary(BaseModel):
|
||||
"""Schema for delivery summary (list view)"""
|
||||
id: UUID
|
||||
delivery_number: str
|
||||
supplier_id: UUID
|
||||
supplier_name: Optional[str] = None
|
||||
purchase_order_id: UUID
|
||||
po_number: Optional[str] = None
|
||||
status: DeliveryStatus
|
||||
scheduled_date: Optional[datetime] = None
|
||||
actual_arrival: Optional[datetime] = None
|
||||
inspection_passed: Optional[bool] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SEARCH AND FILTER SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class SupplierSearchParams(BaseModel):
|
||||
"""Search parameters for suppliers"""
|
||||
search_term: Optional[str] = Field(None, max_length=100)
|
||||
supplier_type: Optional[SupplierType] = None
|
||||
status: Optional[SupplierStatus] = None
|
||||
limit: int = Field(default=50, ge=1, le=1000)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class PurchaseOrderSearchParams(BaseModel):
|
||||
"""Search parameters for purchase orders"""
|
||||
supplier_id: Optional[UUID] = None
|
||||
status: Optional[PurchaseOrderStatus] = None
|
||||
priority: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
search_term: Optional[str] = Field(None, max_length=100)
|
||||
limit: int = Field(default=50, ge=1, le=1000)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class DeliverySearchParams(BaseModel):
|
||||
"""Search parameters for deliveries"""
|
||||
supplier_id: Optional[UUID] = None
|
||||
status: Optional[DeliveryStatus] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
search_term: Optional[str] = Field(None, max_length=100)
|
||||
limit: int = Field(default=50, ge=1, le=1000)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STATISTICS AND REPORTING SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class SupplierStatistics(BaseModel):
|
||||
"""Schema for supplier statistics"""
|
||||
total_suppliers: int
|
||||
active_suppliers: int
|
||||
pending_suppliers: int
|
||||
avg_quality_rating: float
|
||||
avg_delivery_rating: float
|
||||
total_spend: float
|
||||
|
||||
|
||||
class PurchaseOrderStatistics(BaseModel):
|
||||
"""Schema for purchase order statistics"""
|
||||
total_orders: int
|
||||
status_counts: Dict[str, int]
|
||||
this_month_orders: int
|
||||
this_month_spend: float
|
||||
avg_order_value: float
|
||||
overdue_count: int
|
||||
pending_approval: int
|
||||
|
||||
|
||||
class DeliveryPerformanceStats(BaseModel):
|
||||
"""Schema for delivery performance statistics"""
|
||||
total_deliveries: int
|
||||
on_time_deliveries: int
|
||||
late_deliveries: int
|
||||
failed_deliveries: int
|
||||
on_time_percentage: float
|
||||
avg_delay_hours: float
|
||||
quality_pass_rate: float
|
||||
|
||||
|
||||
class DeliverySummaryStats(BaseModel):
|
||||
"""Schema for delivery summary statistics"""
|
||||
todays_deliveries: int
|
||||
this_week_deliveries: int
|
||||
overdue_deliveries: int
|
||||
in_transit_deliveries: int
|
||||
1
services/suppliers/app/services/__init__.py
Normal file
1
services/suppliers/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/services/__init__.py
|
||||
355
services/suppliers/app/services/delivery_service.py
Normal file
355
services/suppliers/app/services/delivery_service.py
Normal file
@@ -0,0 +1,355 @@
|
||||
# services/suppliers/app/services/delivery_service.py
|
||||
"""
|
||||
Delivery service for business logic operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.repositories.delivery_repository import DeliveryRepository
|
||||
from app.repositories.purchase_order_repository import PurchaseOrderRepository
|
||||
from app.repositories.supplier_repository import SupplierRepository
|
||||
from app.models.suppliers import (
|
||||
Delivery, DeliveryItem, DeliveryStatus,
|
||||
PurchaseOrder, PurchaseOrderStatus, SupplierStatus
|
||||
)
|
||||
from app.schemas.suppliers import (
|
||||
DeliveryCreate, DeliveryUpdate, DeliverySearchParams
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class DeliveryService:
|
||||
"""Service for delivery management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.repository = DeliveryRepository(db)
|
||||
self.po_repository = PurchaseOrderRepository(db)
|
||||
self.supplier_repository = SupplierRepository(db)
|
||||
|
||||
async def create_delivery(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
delivery_data: DeliveryCreate,
|
||||
created_by: UUID
|
||||
) -> Delivery:
|
||||
"""Create a new delivery"""
|
||||
logger.info(
|
||||
"Creating delivery",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=str(delivery_data.purchase_order_id)
|
||||
)
|
||||
|
||||
# Validate purchase order exists and belongs to tenant
|
||||
po = self.po_repository.get_by_id(delivery_data.purchase_order_id)
|
||||
if not po:
|
||||
raise ValueError("Purchase order not found")
|
||||
if po.tenant_id != tenant_id:
|
||||
raise ValueError("Purchase order does not belong to this tenant")
|
||||
if po.status not in [
|
||||
PurchaseOrderStatus.CONFIRMED,
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED
|
||||
]:
|
||||
raise ValueError("Purchase order must be confirmed before creating deliveries")
|
||||
|
||||
# Validate supplier
|
||||
supplier = self.supplier_repository.get_by_id(delivery_data.supplier_id)
|
||||
if not supplier:
|
||||
raise ValueError("Supplier not found")
|
||||
if supplier.id != po.supplier_id:
|
||||
raise ValueError("Supplier does not match purchase order supplier")
|
||||
|
||||
# Generate delivery number
|
||||
delivery_number = self.repository.generate_delivery_number(tenant_id)
|
||||
|
||||
# Create delivery
|
||||
delivery_create_data = delivery_data.model_dump(exclude={'items'})
|
||||
delivery_create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'delivery_number': delivery_number,
|
||||
'status': DeliveryStatus.SCHEDULED,
|
||||
'created_by': created_by
|
||||
})
|
||||
|
||||
# Set default scheduled date if not provided
|
||||
if not delivery_create_data.get('scheduled_date'):
|
||||
delivery_create_data['scheduled_date'] = datetime.utcnow()
|
||||
|
||||
delivery = self.repository.create(delivery_create_data)
|
||||
|
||||
# Create delivery items
|
||||
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
|
||||
item_repo = PurchaseOrderItemRepository(self.db)
|
||||
|
||||
for item_data in delivery_data.items:
|
||||
# Validate purchase order item
|
||||
po_item = item_repo.get_by_id(item_data.purchase_order_item_id)
|
||||
if not po_item or po_item.purchase_order_id != po.id:
|
||||
raise ValueError("Invalid purchase order item")
|
||||
|
||||
# Create delivery item
|
||||
from app.models.suppliers import DeliveryItem
|
||||
item_create_data = item_data.model_dump()
|
||||
item_create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'delivery_id': delivery.id
|
||||
})
|
||||
|
||||
delivery_item = DeliveryItem(**item_create_data)
|
||||
self.db.add(delivery_item)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(
|
||||
"Delivery created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
delivery_id=str(delivery.id),
|
||||
delivery_number=delivery_number
|
||||
)
|
||||
|
||||
return delivery
|
||||
|
||||
async def get_delivery(self, delivery_id: UUID) -> Optional[Delivery]:
|
||||
"""Get delivery by ID with items"""
|
||||
return self.repository.get_with_items(delivery_id)
|
||||
|
||||
async def update_delivery(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
delivery_data: DeliveryUpdate,
|
||||
updated_by: UUID
|
||||
) -> Optional[Delivery]:
|
||||
"""Update delivery information"""
|
||||
logger.info("Updating delivery", delivery_id=str(delivery_id))
|
||||
|
||||
delivery = self.repository.get_by_id(delivery_id)
|
||||
if not delivery:
|
||||
return None
|
||||
|
||||
# Check if delivery can be modified
|
||||
if delivery.status in [DeliveryStatus.DELIVERED, DeliveryStatus.FAILED_DELIVERY]:
|
||||
raise ValueError("Cannot modify completed deliveries")
|
||||
|
||||
# Prepare update data
|
||||
update_data = delivery_data.model_dump(exclude_unset=True)
|
||||
update_data['created_by'] = updated_by # Track who updated
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
|
||||
delivery = self.repository.update(delivery_id, update_data)
|
||||
|
||||
logger.info("Delivery updated successfully", delivery_id=str(delivery_id))
|
||||
return delivery
|
||||
|
||||
async def update_delivery_status(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
status: DeliveryStatus,
|
||||
updated_by: UUID,
|
||||
notes: Optional[str] = None,
|
||||
update_timestamps: bool = True
|
||||
) -> Optional[Delivery]:
|
||||
"""Update delivery status"""
|
||||
logger.info("Updating delivery status", delivery_id=str(delivery_id), status=status.value)
|
||||
|
||||
return self.repository.update_delivery_status(
|
||||
delivery_id=delivery_id,
|
||||
status=status,
|
||||
updated_by=updated_by,
|
||||
notes=notes,
|
||||
update_timestamps=update_timestamps
|
||||
)
|
||||
|
||||
async def mark_as_received(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
received_by: UUID,
|
||||
inspection_passed: bool = True,
|
||||
inspection_notes: Optional[str] = None,
|
||||
quality_issues: Optional[Dict[str, Any]] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[Delivery]:
|
||||
"""Mark delivery as received with inspection details"""
|
||||
logger.info("Marking delivery as received", delivery_id=str(delivery_id))
|
||||
|
||||
delivery = self.repository.mark_as_received(
|
||||
delivery_id=delivery_id,
|
||||
received_by=received_by,
|
||||
inspection_passed=inspection_passed,
|
||||
inspection_notes=inspection_notes,
|
||||
quality_issues=quality_issues
|
||||
)
|
||||
|
||||
if not delivery:
|
||||
return None
|
||||
|
||||
# Add custom notes if provided
|
||||
if notes:
|
||||
existing_notes = delivery.notes or ""
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
delivery.notes = f"{existing_notes}\n[{timestamp}] Receipt notes: {notes}".strip()
|
||||
self.repository.update(delivery_id, {'notes': delivery.notes})
|
||||
|
||||
# Update purchase order item received quantities
|
||||
await self._update_purchase_order_received_quantities(delivery)
|
||||
|
||||
# Check if purchase order is fully received
|
||||
await self._check_purchase_order_completion(delivery.purchase_order_id)
|
||||
|
||||
logger.info("Delivery marked as received", delivery_id=str(delivery_id))
|
||||
return delivery
|
||||
|
||||
async def search_deliveries(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_params: DeliverySearchParams
|
||||
) -> List[Delivery]:
|
||||
"""Search deliveries with filters"""
|
||||
return self.repository.search_deliveries(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=search_params.supplier_id,
|
||||
status=search_params.status,
|
||||
date_from=search_params.date_from,
|
||||
date_to=search_params.date_to,
|
||||
search_term=search_params.search_term,
|
||||
limit=search_params.limit,
|
||||
offset=search_params.offset
|
||||
)
|
||||
|
||||
async def get_deliveries_by_purchase_order(self, po_id: UUID) -> List[Delivery]:
|
||||
"""Get all deliveries for a purchase order"""
|
||||
return self.repository.get_by_purchase_order(po_id)
|
||||
|
||||
async def get_todays_deliveries(self, tenant_id: UUID) -> List[Delivery]:
|
||||
"""Get deliveries scheduled for today"""
|
||||
return self.repository.get_todays_deliveries(tenant_id)
|
||||
|
||||
async def get_overdue_deliveries(self, tenant_id: UUID) -> List[Delivery]:
|
||||
"""Get overdue deliveries"""
|
||||
return self.repository.get_overdue_deliveries(tenant_id)
|
||||
|
||||
async def get_scheduled_deliveries(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None
|
||||
) -> List[Delivery]:
|
||||
"""Get scheduled deliveries for date range"""
|
||||
return self.repository.get_scheduled_deliveries(tenant_id, date_from, date_to)
|
||||
|
||||
async def get_delivery_performance_stats(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30,
|
||||
supplier_id: Optional[UUID] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get delivery performance statistics"""
|
||||
return self.repository.get_delivery_performance_stats(
|
||||
tenant_id, days_back, supplier_id
|
||||
)
|
||||
|
||||
async def get_upcoming_deliveries_summary(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get summary of upcoming deliveries"""
|
||||
return self.repository.get_upcoming_deliveries_summary(tenant_id)
|
||||
|
||||
async def _update_purchase_order_received_quantities(self, delivery: Delivery):
|
||||
"""Update purchase order item received quantities based on delivery"""
|
||||
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
|
||||
|
||||
item_repo = PurchaseOrderItemRepository(self.db)
|
||||
|
||||
# Get delivery items with accepted quantities
|
||||
delivery_with_items = self.repository.get_with_items(delivery.id)
|
||||
if not delivery_with_items or not delivery_with_items.items:
|
||||
return
|
||||
|
||||
for delivery_item in delivery_with_items.items:
|
||||
# Update purchase order item received quantity
|
||||
item_repo.add_received_quantity(
|
||||
delivery_item.purchase_order_item_id,
|
||||
delivery_item.accepted_quantity
|
||||
)
|
||||
|
||||
async def _check_purchase_order_completion(self, po_id: UUID):
|
||||
"""Check if purchase order is fully received and update status"""
|
||||
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
|
||||
|
||||
item_repo = PurchaseOrderItemRepository(self.db)
|
||||
po_items = item_repo.get_by_purchase_order(po_id)
|
||||
|
||||
if not po_items:
|
||||
return
|
||||
|
||||
# Check if all items are fully received
|
||||
fully_received = all(item.remaining_quantity == 0 for item in po_items)
|
||||
partially_received = any(item.received_quantity > 0 for item in po_items)
|
||||
|
||||
if fully_received:
|
||||
# Mark purchase order as completed
|
||||
self.po_repository.update_order_status(
|
||||
po_id,
|
||||
PurchaseOrderStatus.COMPLETED,
|
||||
po_items[0].tenant_id, # Use tenant_id as updated_by placeholder
|
||||
"All items received"
|
||||
)
|
||||
elif partially_received:
|
||||
# Mark as partially received if not already
|
||||
po = self.po_repository.get_by_id(po_id)
|
||||
if po and po.status == PurchaseOrderStatus.CONFIRMED:
|
||||
self.po_repository.update_order_status(
|
||||
po_id,
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED,
|
||||
po.tenant_id, # Use tenant_id as updated_by placeholder
|
||||
"Partial delivery received"
|
||||
)
|
||||
|
||||
async def generate_delivery_tracking_info(self, delivery_id: UUID) -> Dict[str, Any]:
|
||||
"""Generate delivery tracking information"""
|
||||
delivery = self.repository.get_with_items(delivery_id)
|
||||
if not delivery:
|
||||
return {}
|
||||
|
||||
# Calculate delivery metrics
|
||||
total_items = len(delivery.items) if delivery.items else 0
|
||||
delivered_items = sum(
|
||||
1 for item in (delivery.items or [])
|
||||
if item.delivered_quantity > 0
|
||||
)
|
||||
accepted_items = sum(
|
||||
1 for item in (delivery.items or [])
|
||||
if item.accepted_quantity > 0
|
||||
)
|
||||
rejected_items = sum(
|
||||
1 for item in (delivery.items or [])
|
||||
if item.rejected_quantity > 0
|
||||
)
|
||||
|
||||
# Calculate timing metrics
|
||||
on_time = False
|
||||
delay_hours = 0
|
||||
if delivery.scheduled_date and delivery.actual_arrival:
|
||||
delay_seconds = (delivery.actual_arrival - delivery.scheduled_date).total_seconds()
|
||||
delay_hours = delay_seconds / 3600
|
||||
on_time = delay_hours <= 0
|
||||
|
||||
return {
|
||||
"delivery_id": str(delivery.id),
|
||||
"delivery_number": delivery.delivery_number,
|
||||
"status": delivery.status.value,
|
||||
"total_items": total_items,
|
||||
"delivered_items": delivered_items,
|
||||
"accepted_items": accepted_items,
|
||||
"rejected_items": rejected_items,
|
||||
"inspection_passed": delivery.inspection_passed,
|
||||
"on_time": on_time,
|
||||
"delay_hours": round(delay_hours, 1) if delay_hours > 0 else 0,
|
||||
"quality_issues": delivery.quality_issues or {},
|
||||
"scheduled_date": delivery.scheduled_date.isoformat() if delivery.scheduled_date else None,
|
||||
"actual_arrival": delivery.actual_arrival.isoformat() if delivery.actual_arrival else None,
|
||||
"completed_at": delivery.completed_at.isoformat() if delivery.completed_at else None
|
||||
}
|
||||
467
services/suppliers/app/services/purchase_order_service.py
Normal file
467
services/suppliers/app/services/purchase_order_service.py
Normal file
@@ -0,0 +1,467 @@
|
||||
# services/suppliers/app/services/purchase_order_service.py
|
||||
"""
|
||||
Purchase Order service for business logic operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from decimal import Decimal
|
||||
|
||||
from app.repositories.purchase_order_repository import PurchaseOrderRepository
|
||||
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
|
||||
from app.repositories.supplier_repository import SupplierRepository
|
||||
from app.models.suppliers import (
|
||||
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus, SupplierStatus
|
||||
)
|
||||
from app.schemas.suppliers import (
|
||||
PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderSearchParams,
|
||||
PurchaseOrderItemCreate, PurchaseOrderItemUpdate
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class PurchaseOrderService:
|
||||
"""Service for purchase order management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.repository = PurchaseOrderRepository(db)
|
||||
self.item_repository = PurchaseOrderItemRepository(db)
|
||||
self.supplier_repository = SupplierRepository(db)
|
||||
|
||||
async def create_purchase_order(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
po_data: PurchaseOrderCreate,
|
||||
created_by: UUID
|
||||
) -> PurchaseOrder:
|
||||
"""Create a new purchase order with items"""
|
||||
logger.info(
|
||||
"Creating purchase order",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(po_data.supplier_id)
|
||||
)
|
||||
|
||||
# Validate supplier exists and is active
|
||||
supplier = self.supplier_repository.get_by_id(po_data.supplier_id)
|
||||
if not supplier:
|
||||
raise ValueError("Supplier not found")
|
||||
if supplier.status != SupplierStatus.ACTIVE:
|
||||
raise ValueError("Cannot create orders for inactive suppliers")
|
||||
if supplier.tenant_id != tenant_id:
|
||||
raise ValueError("Supplier does not belong to this tenant")
|
||||
|
||||
# Generate PO number
|
||||
po_number = self.repository.generate_po_number(tenant_id)
|
||||
|
||||
# Calculate totals from items
|
||||
subtotal = sum(
|
||||
item.ordered_quantity * item.unit_price
|
||||
for item in po_data.items
|
||||
)
|
||||
|
||||
total_amount = (
|
||||
subtotal +
|
||||
po_data.tax_amount +
|
||||
po_data.shipping_cost -
|
||||
po_data.discount_amount
|
||||
)
|
||||
|
||||
# Determine if approval is required
|
||||
requires_approval = (
|
||||
total_amount >= settings.MANAGER_APPROVAL_THRESHOLD or
|
||||
po_data.priority == "urgent"
|
||||
)
|
||||
|
||||
# Set initial status
|
||||
if requires_approval:
|
||||
status = PurchaseOrderStatus.PENDING_APPROVAL
|
||||
elif total_amount <= settings.AUTO_APPROVE_THRESHOLD:
|
||||
status = PurchaseOrderStatus.APPROVED
|
||||
else:
|
||||
status = PurchaseOrderStatus.DRAFT
|
||||
|
||||
# Create purchase order
|
||||
po_create_data = po_data.model_dump(exclude={'items'})
|
||||
po_create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'po_number': po_number,
|
||||
'status': status,
|
||||
'subtotal': subtotal,
|
||||
'total_amount': total_amount,
|
||||
'order_date': datetime.utcnow(),
|
||||
'requires_approval': requires_approval,
|
||||
'currency': supplier.currency,
|
||||
'created_by': created_by,
|
||||
'updated_by': created_by
|
||||
})
|
||||
|
||||
# Set delivery date if not provided
|
||||
if not po_create_data.get('required_delivery_date'):
|
||||
po_create_data['required_delivery_date'] = (
|
||||
datetime.utcnow() + timedelta(days=supplier.standard_lead_time)
|
||||
)
|
||||
|
||||
purchase_order = self.repository.create(po_create_data)
|
||||
|
||||
# Create purchase order items
|
||||
for item_data in po_data.items:
|
||||
item_create_data = item_data.model_dump()
|
||||
item_create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'purchase_order_id': purchase_order.id,
|
||||
'line_total': item_data.ordered_quantity * item_data.unit_price,
|
||||
'remaining_quantity': item_data.ordered_quantity
|
||||
})
|
||||
|
||||
self.item_repository.create(item_create_data)
|
||||
|
||||
logger.info(
|
||||
"Purchase order created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=str(purchase_order.id),
|
||||
po_number=po_number,
|
||||
total_amount=float(total_amount)
|
||||
)
|
||||
|
||||
return purchase_order
|
||||
|
||||
async def get_purchase_order(self, po_id: UUID) -> Optional[PurchaseOrder]:
|
||||
"""Get purchase order by ID with items"""
|
||||
return self.repository.get_with_items(po_id)
|
||||
|
||||
async def update_purchase_order(
|
||||
self,
|
||||
po_id: UUID,
|
||||
po_data: PurchaseOrderUpdate,
|
||||
updated_by: UUID
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Update purchase order information"""
|
||||
logger.info("Updating purchase order", po_id=str(po_id))
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
# Check if order can be modified
|
||||
if po.status in [
|
||||
PurchaseOrderStatus.COMPLETED,
|
||||
PurchaseOrderStatus.CANCELLED
|
||||
]:
|
||||
raise ValueError("Cannot modify completed or cancelled orders")
|
||||
|
||||
# Prepare update data
|
||||
update_data = po_data.model_dump(exclude_unset=True)
|
||||
update_data['updated_by'] = updated_by
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
|
||||
# Recalculate totals if financial fields changed
|
||||
if any(key in update_data for key in ['tax_amount', 'shipping_cost', 'discount_amount']):
|
||||
po = self.repository.calculate_order_totals(po_id)
|
||||
|
||||
po = self.repository.update(po_id, update_data)
|
||||
|
||||
logger.info("Purchase order updated successfully", po_id=str(po_id))
|
||||
return po
|
||||
|
||||
async def update_order_status(
|
||||
self,
|
||||
po_id: UUID,
|
||||
status: PurchaseOrderStatus,
|
||||
updated_by: UUID,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Update purchase order status"""
|
||||
logger.info("Updating PO status", po_id=str(po_id), status=status.value)
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
# Validate status transition
|
||||
if not self._is_valid_status_transition(po.status, status):
|
||||
raise ValueError(f"Invalid status transition from {po.status.value} to {status.value}")
|
||||
|
||||
return self.repository.update_order_status(po_id, status, updated_by, notes)
|
||||
|
||||
async def approve_purchase_order(
|
||||
self,
|
||||
po_id: UUID,
|
||||
approved_by: UUID,
|
||||
approval_notes: Optional[str] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Approve a purchase order"""
|
||||
logger.info("Approving purchase order", po_id=str(po_id))
|
||||
|
||||
po = self.repository.approve_order(po_id, approved_by, approval_notes)
|
||||
if not po:
|
||||
logger.warning("Failed to approve PO - not found or not pending approval")
|
||||
return None
|
||||
|
||||
logger.info("Purchase order approved successfully", po_id=str(po_id))
|
||||
return po
|
||||
|
||||
async def reject_purchase_order(
|
||||
self,
|
||||
po_id: UUID,
|
||||
rejection_reason: str,
|
||||
rejected_by: UUID
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Reject a purchase order"""
|
||||
logger.info("Rejecting purchase order", po_id=str(po_id))
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po or po.status != PurchaseOrderStatus.PENDING_APPROVAL:
|
||||
return None
|
||||
|
||||
update_data = {
|
||||
'status': PurchaseOrderStatus.CANCELLED,
|
||||
'rejection_reason': rejection_reason,
|
||||
'approved_by': rejected_by,
|
||||
'approved_at': datetime.utcnow(),
|
||||
'updated_by': rejected_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
po = self.repository.update(po_id, update_data)
|
||||
logger.info("Purchase order rejected successfully", po_id=str(po_id))
|
||||
return po
|
||||
|
||||
async def send_to_supplier(
|
||||
self,
|
||||
po_id: UUID,
|
||||
sent_by: UUID,
|
||||
send_email: bool = True
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Send purchase order to supplier"""
|
||||
logger.info("Sending PO to supplier", po_id=str(po_id))
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
if po.status != PurchaseOrderStatus.APPROVED:
|
||||
raise ValueError("Only approved orders can be sent to suppliers")
|
||||
|
||||
# Update status and timestamp
|
||||
po = self.repository.update_order_status(
|
||||
po_id,
|
||||
PurchaseOrderStatus.SENT_TO_SUPPLIER,
|
||||
sent_by,
|
||||
"Order sent to supplier"
|
||||
)
|
||||
|
||||
# TODO: Send email to supplier if send_email is True
|
||||
# This would integrate with notification service
|
||||
|
||||
logger.info("Purchase order sent to supplier", po_id=str(po_id))
|
||||
return po
|
||||
|
||||
async def confirm_supplier_receipt(
|
||||
self,
|
||||
po_id: UUID,
|
||||
supplier_reference: Optional[str] = None,
|
||||
confirmed_by: UUID = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Confirm supplier has received and accepted the order"""
|
||||
logger.info("Confirming supplier receipt", po_id=str(po_id))
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
if po.status != PurchaseOrderStatus.SENT_TO_SUPPLIER:
|
||||
raise ValueError("Order must be sent to supplier before confirmation")
|
||||
|
||||
update_data = {
|
||||
'status': PurchaseOrderStatus.CONFIRMED,
|
||||
'supplier_confirmation_date': datetime.utcnow(),
|
||||
'supplier_reference': supplier_reference,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
if confirmed_by:
|
||||
update_data['updated_by'] = confirmed_by
|
||||
|
||||
po = self.repository.update(po_id, update_data)
|
||||
logger.info("Supplier receipt confirmed", po_id=str(po_id))
|
||||
return po
|
||||
|
||||
async def search_purchase_orders(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_params: PurchaseOrderSearchParams
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Search purchase orders with filters"""
|
||||
return self.repository.search_purchase_orders(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=search_params.supplier_id,
|
||||
status=search_params.status,
|
||||
priority=search_params.priority,
|
||||
date_from=search_params.date_from,
|
||||
date_to=search_params.date_to,
|
||||
search_term=search_params.search_term,
|
||||
limit=search_params.limit,
|
||||
offset=search_params.offset
|
||||
)
|
||||
|
||||
async def get_orders_by_supplier(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_id: UUID,
|
||||
limit: int = 20
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Get recent orders for a supplier"""
|
||||
return self.repository.get_orders_by_supplier(tenant_id, supplier_id, limit)
|
||||
|
||||
async def get_orders_requiring_approval(
|
||||
self,
|
||||
tenant_id: UUID
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Get orders pending approval"""
|
||||
return self.repository.get_orders_requiring_approval(tenant_id)
|
||||
|
||||
async def get_overdue_orders(self, tenant_id: UUID) -> List[PurchaseOrder]:
|
||||
"""Get orders that are overdue for delivery"""
|
||||
return self.repository.get_overdue_orders(tenant_id)
|
||||
|
||||
async def get_purchase_order_statistics(
|
||||
self,
|
||||
tenant_id: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Get purchase order statistics"""
|
||||
return self.repository.get_purchase_order_statistics(tenant_id)
|
||||
|
||||
async def update_order_items(
|
||||
self,
|
||||
po_id: UUID,
|
||||
items_updates: List[Dict[str, Any]],
|
||||
updated_by: UUID
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Update multiple items in a purchase order"""
|
||||
logger.info("Updating order items", po_id=str(po_id))
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
# Check if order can be modified
|
||||
if po.status in [
|
||||
PurchaseOrderStatus.COMPLETED,
|
||||
PurchaseOrderStatus.CANCELLED,
|
||||
PurchaseOrderStatus.SENT_TO_SUPPLIER,
|
||||
PurchaseOrderStatus.CONFIRMED
|
||||
]:
|
||||
raise ValueError("Cannot modify items for orders in current status")
|
||||
|
||||
# Update items
|
||||
self.item_repository.bulk_update_items(po_id, items_updates)
|
||||
|
||||
# Recalculate order totals
|
||||
po = self.repository.calculate_order_totals(po_id)
|
||||
|
||||
# Update the order timestamp
|
||||
self.repository.update(po_id, {
|
||||
'updated_by': updated_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
})
|
||||
|
||||
logger.info("Order items updated successfully", po_id=str(po_id))
|
||||
return po
|
||||
|
||||
async def cancel_purchase_order(
|
||||
self,
|
||||
po_id: UUID,
|
||||
cancellation_reason: str,
|
||||
cancelled_by: UUID
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Cancel a purchase order"""
|
||||
logger.info("Cancelling purchase order", po_id=str(po_id))
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
if po.status in [PurchaseOrderStatus.COMPLETED, PurchaseOrderStatus.CANCELLED]:
|
||||
raise ValueError("Cannot cancel completed or already cancelled orders")
|
||||
|
||||
update_data = {
|
||||
'status': PurchaseOrderStatus.CANCELLED,
|
||||
'rejection_reason': cancellation_reason,
|
||||
'updated_by': cancelled_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
po = self.repository.update(po_id, update_data)
|
||||
|
||||
logger.info("Purchase order cancelled successfully", po_id=str(po_id))
|
||||
return po
|
||||
|
||||
def _is_valid_status_transition(
|
||||
self,
|
||||
from_status: PurchaseOrderStatus,
|
||||
to_status: PurchaseOrderStatus
|
||||
) -> bool:
|
||||
"""Validate if status transition is allowed"""
|
||||
# Define valid transitions
|
||||
valid_transitions = {
|
||||
PurchaseOrderStatus.DRAFT: [
|
||||
PurchaseOrderStatus.PENDING_APPROVAL,
|
||||
PurchaseOrderStatus.APPROVED,
|
||||
PurchaseOrderStatus.CANCELLED
|
||||
],
|
||||
PurchaseOrderStatus.PENDING_APPROVAL: [
|
||||
PurchaseOrderStatus.APPROVED,
|
||||
PurchaseOrderStatus.CANCELLED
|
||||
],
|
||||
PurchaseOrderStatus.APPROVED: [
|
||||
PurchaseOrderStatus.SENT_TO_SUPPLIER,
|
||||
PurchaseOrderStatus.CANCELLED
|
||||
],
|
||||
PurchaseOrderStatus.SENT_TO_SUPPLIER: [
|
||||
PurchaseOrderStatus.CONFIRMED,
|
||||
PurchaseOrderStatus.CANCELLED
|
||||
],
|
||||
PurchaseOrderStatus.CONFIRMED: [
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED,
|
||||
PurchaseOrderStatus.COMPLETED,
|
||||
PurchaseOrderStatus.DISPUTED
|
||||
],
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED: [
|
||||
PurchaseOrderStatus.COMPLETED,
|
||||
PurchaseOrderStatus.DISPUTED
|
||||
],
|
||||
PurchaseOrderStatus.DISPUTED: [
|
||||
PurchaseOrderStatus.CONFIRMED,
|
||||
PurchaseOrderStatus.CANCELLED
|
||||
]
|
||||
}
|
||||
|
||||
return to_status in valid_transitions.get(from_status, [])
|
||||
|
||||
async def get_ingredient_purchase_history(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
days_back: int = 90
|
||||
) -> Dict[str, Any]:
|
||||
"""Get purchase history for an ingredient"""
|
||||
return self.item_repository.get_ingredient_purchase_history(
|
||||
tenant_id, ingredient_id, days_back
|
||||
)
|
||||
|
||||
async def get_top_purchased_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get most purchased ingredients"""
|
||||
return self.item_repository.get_top_purchased_ingredients(
|
||||
tenant_id, days_back, limit
|
||||
)
|
||||
321
services/suppliers/app/services/supplier_service.py
Normal file
321
services/suppliers/app/services/supplier_service.py
Normal file
@@ -0,0 +1,321 @@
|
||||
# services/suppliers/app/services/supplier_service.py
|
||||
"""
|
||||
Supplier service for business logic operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.repositories.supplier_repository import SupplierRepository
|
||||
from app.models.suppliers import Supplier, SupplierStatus, SupplierType
|
||||
from app.schemas.suppliers import (
|
||||
SupplierCreate, SupplierUpdate, SupplierResponse,
|
||||
SupplierSearchParams, SupplierStatistics
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SupplierService:
|
||||
"""Service for supplier management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.repository = SupplierRepository(db)
|
||||
|
||||
async def create_supplier(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_data: SupplierCreate,
|
||||
created_by: UUID
|
||||
) -> Supplier:
|
||||
"""Create a new supplier"""
|
||||
logger.info("Creating supplier", tenant_id=str(tenant_id), name=supplier_data.name)
|
||||
|
||||
# Check for duplicate name
|
||||
existing = self.repository.get_by_name(tenant_id, supplier_data.name)
|
||||
if existing:
|
||||
raise ValueError(f"Supplier with name '{supplier_data.name}' already exists")
|
||||
|
||||
# Check for duplicate supplier code if provided
|
||||
if supplier_data.supplier_code:
|
||||
existing_code = self.repository.get_by_supplier_code(
|
||||
tenant_id, supplier_data.supplier_code
|
||||
)
|
||||
if existing_code:
|
||||
raise ValueError(
|
||||
f"Supplier with code '{supplier_data.supplier_code}' already exists"
|
||||
)
|
||||
|
||||
# Generate supplier code if not provided
|
||||
supplier_code = supplier_data.supplier_code
|
||||
if not supplier_code:
|
||||
supplier_code = self._generate_supplier_code(supplier_data.name)
|
||||
|
||||
# Create supplier data
|
||||
create_data = supplier_data.model_dump(exclude_unset=True)
|
||||
create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'supplier_code': supplier_code,
|
||||
'status': SupplierStatus.PENDING_APPROVAL,
|
||||
'created_by': created_by,
|
||||
'updated_by': created_by,
|
||||
'quality_rating': 0.0,
|
||||
'delivery_rating': 0.0,
|
||||
'total_orders': 0,
|
||||
'total_amount': 0.0
|
||||
})
|
||||
|
||||
supplier = self.repository.create(create_data)
|
||||
|
||||
logger.info(
|
||||
"Supplier created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier.id),
|
||||
name=supplier.name
|
||||
)
|
||||
|
||||
return supplier
|
||||
|
||||
async def get_supplier(self, supplier_id: UUID) -> Optional[Supplier]:
|
||||
"""Get supplier by ID"""
|
||||
return self.repository.get_by_id(supplier_id)
|
||||
|
||||
async def update_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
supplier_data: SupplierUpdate,
|
||||
updated_by: UUID
|
||||
) -> Optional[Supplier]:
|
||||
"""Update supplier information"""
|
||||
logger.info("Updating supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.get_by_id(supplier_id)
|
||||
if not supplier:
|
||||
return None
|
||||
|
||||
# Check for duplicate name if changing
|
||||
if supplier_data.name and supplier_data.name != supplier.name:
|
||||
existing = self.repository.get_by_name(supplier.tenant_id, supplier_data.name)
|
||||
if existing:
|
||||
raise ValueError(f"Supplier with name '{supplier_data.name}' already exists")
|
||||
|
||||
# Check for duplicate supplier code if changing
|
||||
if (supplier_data.supplier_code and
|
||||
supplier_data.supplier_code != supplier.supplier_code):
|
||||
existing_code = self.repository.get_by_supplier_code(
|
||||
supplier.tenant_id, supplier_data.supplier_code
|
||||
)
|
||||
if existing_code:
|
||||
raise ValueError(
|
||||
f"Supplier with code '{supplier_data.supplier_code}' already exists"
|
||||
)
|
||||
|
||||
# Prepare update data
|
||||
update_data = supplier_data.model_dump(exclude_unset=True)
|
||||
update_data['updated_by'] = updated_by
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
|
||||
supplier = self.repository.update(supplier_id, update_data)
|
||||
|
||||
logger.info("Supplier updated successfully", supplier_id=str(supplier_id))
|
||||
return supplier
|
||||
|
||||
async def delete_supplier(self, supplier_id: UUID) -> bool:
|
||||
"""Delete supplier (soft delete by changing status)"""
|
||||
logger.info("Deleting supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.get_by_id(supplier_id)
|
||||
if not supplier:
|
||||
return False
|
||||
|
||||
# Check if supplier has active purchase orders
|
||||
# TODO: Add check for active purchase orders once PO service is implemented
|
||||
|
||||
# Soft delete by changing status
|
||||
self.repository.update(supplier_id, {
|
||||
'status': SupplierStatus.INACTIVE,
|
||||
'updated_at': datetime.utcnow()
|
||||
})
|
||||
|
||||
logger.info("Supplier deleted successfully", supplier_id=str(supplier_id))
|
||||
return True
|
||||
|
||||
async def search_suppliers(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_params: SupplierSearchParams
|
||||
) -> List[Supplier]:
|
||||
"""Search suppliers with filters"""
|
||||
return self.repository.search_suppliers(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_params.search_term,
|
||||
supplier_type=search_params.supplier_type,
|
||||
status=search_params.status,
|
||||
limit=search_params.limit,
|
||||
offset=search_params.offset
|
||||
)
|
||||
|
||||
async def get_active_suppliers(self, tenant_id: UUID) -> List[Supplier]:
|
||||
"""Get all active suppliers"""
|
||||
return self.repository.get_active_suppliers(tenant_id)
|
||||
|
||||
async def get_suppliers_by_type(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_type: SupplierType
|
||||
) -> List[Supplier]:
|
||||
"""Get suppliers by type"""
|
||||
return self.repository.get_suppliers_by_type(tenant_id, supplier_type)
|
||||
|
||||
async def get_top_suppliers(self, tenant_id: UUID, limit: int = 10) -> List[Supplier]:
|
||||
"""Get top performing suppliers"""
|
||||
return self.repository.get_top_suppliers(tenant_id, limit)
|
||||
|
||||
async def approve_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
approved_by: UUID,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[Supplier]:
|
||||
"""Approve a pending supplier"""
|
||||
logger.info("Approving supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.approve_supplier(supplier_id, approved_by)
|
||||
if not supplier:
|
||||
logger.warning("Failed to approve supplier - not found or not pending")
|
||||
return None
|
||||
|
||||
if notes:
|
||||
self.repository.update(supplier_id, {
|
||||
'notes': (supplier.notes or "") + f"\nApproval notes: {notes}",
|
||||
'updated_at': datetime.utcnow()
|
||||
})
|
||||
|
||||
logger.info("Supplier approved successfully", supplier_id=str(supplier_id))
|
||||
return supplier
|
||||
|
||||
async def reject_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
rejection_reason: str,
|
||||
rejected_by: UUID
|
||||
) -> Optional[Supplier]:
|
||||
"""Reject a pending supplier"""
|
||||
logger.info("Rejecting supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.reject_supplier(
|
||||
supplier_id, rejection_reason, rejected_by
|
||||
)
|
||||
if not supplier:
|
||||
logger.warning("Failed to reject supplier - not found or not pending")
|
||||
return None
|
||||
|
||||
logger.info("Supplier rejected successfully", supplier_id=str(supplier_id))
|
||||
return supplier
|
||||
|
||||
async def update_supplier_performance(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
quality_rating: Optional[float] = None,
|
||||
delivery_rating: Optional[float] = None,
|
||||
order_increment: int = 0,
|
||||
amount_increment: float = 0.0
|
||||
) -> Optional[Supplier]:
|
||||
"""Update supplier performance metrics"""
|
||||
logger.info("Updating supplier performance", supplier_id=str(supplier_id))
|
||||
|
||||
return self.repository.update_supplier_stats(
|
||||
supplier_id=supplier_id,
|
||||
total_orders_increment=order_increment,
|
||||
total_amount_increment=amount_increment,
|
||||
new_quality_rating=quality_rating,
|
||||
new_delivery_rating=delivery_rating
|
||||
)
|
||||
|
||||
async def get_supplier_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get supplier statistics for dashboard"""
|
||||
return self.repository.get_supplier_statistics(tenant_id)
|
||||
|
||||
async def get_suppliers_needing_review(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_since_last_order: int = 30
|
||||
) -> List[Supplier]:
|
||||
"""Get suppliers that may need performance review"""
|
||||
return self.repository.get_suppliers_needing_review(
|
||||
tenant_id, days_since_last_order
|
||||
)
|
||||
|
||||
def _generate_supplier_code(self, supplier_name: str) -> str:
|
||||
"""Generate supplier code from name"""
|
||||
# Take first 3 characters of each word, uppercase
|
||||
words = supplier_name.strip().split()[:3] # Max 3 words
|
||||
code_parts = []
|
||||
|
||||
for word in words:
|
||||
if len(word) >= 3:
|
||||
code_parts.append(word[:3].upper())
|
||||
else:
|
||||
code_parts.append(word.upper())
|
||||
|
||||
base_code = "".join(code_parts)[:8] # Max 8 characters
|
||||
|
||||
# Add random suffix to ensure uniqueness
|
||||
import random
|
||||
import string
|
||||
suffix = ''.join(random.choices(string.digits, k=2))
|
||||
|
||||
return f"{base_code}{suffix}"
|
||||
|
||||
async def validate_supplier_data(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_data: Dict[str, Any],
|
||||
supplier_id: Optional[UUID] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Validate supplier data and return errors"""
|
||||
errors = {}
|
||||
|
||||
# Check required fields
|
||||
if not supplier_data.get('name'):
|
||||
errors['name'] = "Supplier name is required"
|
||||
|
||||
# Check email format if provided
|
||||
email = supplier_data.get('email')
|
||||
if email:
|
||||
import re
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(email_pattern, email):
|
||||
errors['email'] = "Invalid email format"
|
||||
|
||||
# Check phone format if provided
|
||||
phone = supplier_data.get('phone')
|
||||
if phone:
|
||||
# Basic phone validation (digits, spaces, dashes, parentheses)
|
||||
import re
|
||||
phone_pattern = r'^[\d\s\-\(\)\+]+$'
|
||||
if not re.match(phone_pattern, phone):
|
||||
errors['phone'] = "Invalid phone format"
|
||||
|
||||
# Check lead time range
|
||||
lead_time = supplier_data.get('standard_lead_time')
|
||||
if lead_time is not None:
|
||||
if lead_time < 0 or lead_time > 365:
|
||||
errors['standard_lead_time'] = "Lead time must be between 0 and 365 days"
|
||||
|
||||
# Check credit limit
|
||||
credit_limit = supplier_data.get('credit_limit')
|
||||
if credit_limit is not None and credit_limit < 0:
|
||||
errors['credit_limit'] = "Credit limit cannot be negative"
|
||||
|
||||
# Check minimum order amount
|
||||
min_order = supplier_data.get('minimum_order_amount')
|
||||
if min_order is not None and min_order < 0:
|
||||
errors['minimum_order_amount'] = "Minimum order amount cannot be negative"
|
||||
|
||||
return errors
|
||||
Reference in New Issue
Block a user