Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

@@ -0,0 +1,33 @@
# services/suppliers/Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY services/suppliers/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy shared modules first
COPY shared/ /app/shared/
# Copy application code
COPY services/suppliers/app/ /app/app/
# Set Python path to include shared modules
ENV PYTHONPATH=/app
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5)" || exit 1
# Run the application
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1 @@
# services/suppliers/app/__init__.py

View File

@@ -0,0 +1 @@
# services/suppliers/app/api/__init__.py

View 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")

View 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")

View 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")

View File

@@ -0,0 +1 @@
# services/suppliers/app/core/__init__.py

View 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()

View 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()

View 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"
)

View File

@@ -0,0 +1 @@
# services/suppliers/app/models/__init__.py

View 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'),
)

View File

@@ -0,0 +1 @@
# services/suppliers/app/repositories/__init__.py

View 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

View 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
}

View File

@@ -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

View 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)
}

View 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

View File

@@ -0,0 +1 @@
# services/suppliers/app/schemas/__init__.py

View 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

View File

@@ -0,0 +1 @@
# services/suppliers/app/services/__init__.py

View 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
}

View 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
)

View 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

View File

@@ -0,0 +1,93 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version number format
# Uses Alembic datetime format
version_num_format = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d
# version name format
version_path_separator = /
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = postgresql+asyncpg://suppliers_user:suppliers_pass123@suppliers-db:5432/suppliers_db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,109 @@
"""
Alembic environment configuration for Suppliers Service
"""
import asyncio
from logging.config import fileConfig
import os
import sys
from pathlib import Path
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Add the app directory to the path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Import models to ensure they're registered
from app.models.suppliers import * # noqa
from shared.database.base import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the SQLAlchemy URL from environment variable if available
database_url = os.getenv('SUPPLIERS_DATABASE_URL')
if database_url:
config.set_main_option('sqlalchemy.url', database_url)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Run migrations with database connection"""
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in async mode"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,404 @@
"""Initial supplier and procurement tables
Revision ID: 001_initial_supplier_tables
Revises:
Create Date: 2024-01-15 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
# revision identifiers, used by Alembic.
revision = '001_initial_supplier_tables'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create suppliers table
op.create_table('suppliers',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('supplier_code', sa.String(50), nullable=True),
sa.Column('tax_id', sa.String(50), nullable=True),
sa.Column('registration_number', sa.String(100), nullable=True),
sa.Column('supplier_type', sa.Enum('INGREDIENTS', 'PACKAGING', 'EQUIPMENT', 'SERVICES', 'UTILITIES', 'MULTI', name='suppliertype'), nullable=False),
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'PENDING_APPROVAL', 'SUSPENDED', 'BLACKLISTED', name='supplierstatus'), nullable=False, default='PENDING_APPROVAL'),
sa.Column('contact_person', sa.String(200), nullable=True),
sa.Column('email', sa.String(254), nullable=True),
sa.Column('phone', sa.String(30), nullable=True),
sa.Column('mobile', sa.String(30), nullable=True),
sa.Column('website', sa.String(255), nullable=True),
sa.Column('address_line1', sa.String(255), nullable=True),
sa.Column('address_line2', sa.String(255), nullable=True),
sa.Column('city', sa.String(100), nullable=True),
sa.Column('state_province', sa.String(100), nullable=True),
sa.Column('postal_code', sa.String(20), nullable=True),
sa.Column('country', sa.String(100), nullable=True),
sa.Column('payment_terms', sa.Enum('CASH_ON_DELIVERY', 'NET_15', 'NET_30', 'NET_45', 'NET_60', 'PREPAID', 'CREDIT_TERMS', name='paymentterms'), nullable=False, default='NET_30'),
sa.Column('credit_limit', sa.Numeric(12, 2), nullable=True),
sa.Column('currency', sa.String(3), nullable=False, default='EUR'),
sa.Column('standard_lead_time', sa.Integer(), nullable=False, default=3),
sa.Column('minimum_order_amount', sa.Numeric(10, 2), nullable=True),
sa.Column('delivery_area', sa.String(255), nullable=True),
sa.Column('quality_rating', sa.Float(), nullable=True, default=0.0),
sa.Column('delivery_rating', sa.Float(), nullable=True, default=0.0),
sa.Column('total_orders', sa.Integer(), nullable=False, default=0),
sa.Column('total_amount', sa.Numeric(12, 2), nullable=False, default=0.0),
sa.Column('approved_by', UUID(as_uuid=True), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('certifications', JSONB, nullable=True),
sa.Column('business_hours', JSONB, nullable=True),
sa.Column('specializations', JSONB, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', UUID(as_uuid=True), nullable=False),
sa.Column('updated_by', UUID(as_uuid=True), nullable=False)
)
# Create supplier_price_lists table
op.create_table('supplier_price_lists',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_id', UUID(as_uuid=True), nullable=False),
sa.Column('product_code', sa.String(100), nullable=True),
sa.Column('product_name', sa.String(255), nullable=False),
sa.Column('unit_price', sa.Numeric(10, 4), nullable=False),
sa.Column('unit_of_measure', sa.String(20), nullable=False),
sa.Column('minimum_order_quantity', sa.Integer(), nullable=True, default=1),
sa.Column('price_per_unit', sa.Numeric(10, 4), nullable=False),
sa.Column('tier_pricing', JSONB, nullable=True),
sa.Column('effective_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('expiry_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('brand', sa.String(100), nullable=True),
sa.Column('packaging_size', sa.String(50), nullable=True),
sa.Column('origin_country', sa.String(100), nullable=True),
sa.Column('shelf_life_days', sa.Integer(), nullable=True),
sa.Column('storage_requirements', sa.Text(), nullable=True),
sa.Column('quality_specs', JSONB, nullable=True),
sa.Column('allergens', JSONB, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', UUID(as_uuid=True), nullable=False),
sa.Column('updated_by', UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'])
)
# Create purchase_orders table
op.create_table('purchase_orders',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', UUID(as_uuid=True), nullable=False),
sa.Column('po_number', sa.String(50), nullable=False),
sa.Column('reference_number', sa.String(100), nullable=True),
sa.Column('status', sa.Enum('DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'SENT_TO_SUPPLIER', 'CONFIRMED', 'PARTIALLY_RECEIVED', 'COMPLETED', 'CANCELLED', 'DISPUTED', name='purchaseorderstatus'), nullable=False, default='DRAFT'),
sa.Column('priority', sa.String(20), nullable=False, default='normal'),
sa.Column('order_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('required_delivery_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('estimated_delivery_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('subtotal', sa.Numeric(12, 2), nullable=False, default=0.0),
sa.Column('tax_amount', sa.Numeric(12, 2), nullable=False, default=0.0),
sa.Column('shipping_cost', sa.Numeric(10, 2), nullable=False, default=0.0),
sa.Column('discount_amount', sa.Numeric(10, 2), nullable=False, default=0.0),
sa.Column('total_amount', sa.Numeric(12, 2), nullable=False, default=0.0),
sa.Column('currency', sa.String(3), nullable=False, default='EUR'),
sa.Column('delivery_address', sa.Text(), nullable=True),
sa.Column('delivery_instructions', sa.Text(), nullable=True),
sa.Column('delivery_contact', sa.String(200), nullable=True),
sa.Column('delivery_phone', sa.String(30), nullable=True),
sa.Column('requires_approval', sa.Boolean(), nullable=False, default=False),
sa.Column('approved_by', UUID(as_uuid=True), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('sent_to_supplier_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('supplier_confirmation_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('supplier_reference', sa.String(100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('internal_notes', sa.Text(), nullable=True),
sa.Column('terms_and_conditions', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', UUID(as_uuid=True), nullable=False),
sa.Column('updated_by', UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'])
)
# Create purchase_order_items table
op.create_table('purchase_order_items',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_id', UUID(as_uuid=True), nullable=False),
sa.Column('price_list_item_id', UUID(as_uuid=True), nullable=True),
sa.Column('ingredient_id', UUID(as_uuid=True), nullable=False),
sa.Column('product_code', sa.String(100), nullable=True),
sa.Column('product_name', sa.String(255), nullable=False),
sa.Column('ordered_quantity', sa.Integer(), nullable=False),
sa.Column('unit_of_measure', sa.String(20), nullable=False),
sa.Column('unit_price', sa.Numeric(10, 4), nullable=False),
sa.Column('line_total', sa.Numeric(12, 2), nullable=False),
sa.Column('received_quantity', sa.Integer(), nullable=False, default=0),
sa.Column('remaining_quantity', sa.Integer(), nullable=False, default=0),
sa.Column('quality_requirements', sa.Text(), nullable=True),
sa.Column('item_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id']),
sa.ForeignKeyConstraint(['price_list_item_id'], ['supplier_price_lists.id'])
)
# Create deliveries table
op.create_table('deliveries',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_id', UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', UUID(as_uuid=True), nullable=False),
sa.Column('delivery_number', sa.String(50), nullable=False),
sa.Column('supplier_delivery_note', sa.String(100), nullable=True),
sa.Column('status', sa.Enum('SCHEDULED', 'IN_TRANSIT', 'OUT_FOR_DELIVERY', 'DELIVERED', 'PARTIALLY_DELIVERED', 'FAILED_DELIVERY', 'RETURNED', name='deliverystatus'), nullable=False, default='SCHEDULED'),
sa.Column('scheduled_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('estimated_arrival', sa.DateTime(timezone=True), nullable=True),
sa.Column('actual_arrival', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('delivery_address', sa.Text(), nullable=True),
sa.Column('delivery_contact', sa.String(200), nullable=True),
sa.Column('delivery_phone', sa.String(30), nullable=True),
sa.Column('carrier_name', sa.String(200), nullable=True),
sa.Column('tracking_number', sa.String(100), nullable=True),
sa.Column('inspection_passed', sa.Boolean(), nullable=True),
sa.Column('inspection_notes', sa.Text(), nullable=True),
sa.Column('quality_issues', JSONB, nullable=True),
sa.Column('received_by', UUID(as_uuid=True), nullable=True),
sa.Column('received_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('photos', JSONB, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id']),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'])
)
# Create delivery_items table
op.create_table('delivery_items',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('delivery_id', UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_item_id', UUID(as_uuid=True), nullable=False),
sa.Column('ingredient_id', UUID(as_uuid=True), nullable=False),
sa.Column('product_name', sa.String(255), nullable=False),
sa.Column('ordered_quantity', sa.Integer(), nullable=False),
sa.Column('delivered_quantity', sa.Integer(), nullable=False),
sa.Column('accepted_quantity', sa.Integer(), nullable=False),
sa.Column('rejected_quantity', sa.Integer(), nullable=False, default=0),
sa.Column('batch_lot_number', sa.String(100), nullable=True),
sa.Column('expiry_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('quality_grade', sa.String(20), nullable=True),
sa.Column('quality_issues', sa.Text(), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('item_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['delivery_id'], ['deliveries.id']),
sa.ForeignKeyConstraint(['purchase_order_item_id'], ['purchase_order_items.id'])
)
# Create supplier_quality_reviews table
op.create_table('supplier_quality_reviews',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_id', UUID(as_uuid=True), nullable=True),
sa.Column('delivery_id', UUID(as_uuid=True), nullable=True),
sa.Column('review_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('review_type', sa.String(50), nullable=False),
sa.Column('quality_rating', sa.Enum('EXCELLENT', 'GOOD', 'AVERAGE', 'POOR', 'VERY_POOR', name='qualityrating'), nullable=False),
sa.Column('delivery_rating', sa.Enum('EXCELLENT', 'GOOD', 'AVERAGE', 'POOR', 'VERY_POOR', name='deliveryrating'), nullable=False),
sa.Column('communication_rating', sa.Integer(), nullable=False),
sa.Column('overall_rating', sa.Float(), nullable=False),
sa.Column('quality_comments', sa.Text(), nullable=True),
sa.Column('delivery_comments', sa.Text(), nullable=True),
sa.Column('communication_comments', sa.Text(), nullable=True),
sa.Column('improvement_suggestions', sa.Text(), nullable=True),
sa.Column('quality_issues', JSONB, nullable=True),
sa.Column('corrective_actions', sa.Text(), nullable=True),
sa.Column('follow_up_required', sa.Boolean(), nullable=False, default=False),
sa.Column('follow_up_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_final', sa.Boolean(), nullable=False, default=True),
sa.Column('approved_by', UUID(as_uuid=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reviewed_by', UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id']),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id']),
sa.ForeignKeyConstraint(['delivery_id'], ['deliveries.id'])
)
# Create supplier_invoices table
op.create_table('supplier_invoices',
sa.Column('id', UUID(as_uuid=True), nullable=False, primary_key=True),
sa.Column('tenant_id', UUID(as_uuid=True), nullable=False),
sa.Column('supplier_id', UUID(as_uuid=True), nullable=False),
sa.Column('purchase_order_id', UUID(as_uuid=True), nullable=True),
sa.Column('invoice_number', sa.String(50), nullable=False),
sa.Column('supplier_invoice_number', sa.String(100), nullable=False),
sa.Column('status', sa.Enum('PENDING', 'APPROVED', 'PAID', 'OVERDUE', 'DISPUTED', 'CANCELLED', name='invoicestatus'), nullable=False, default='PENDING'),
sa.Column('invoice_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('due_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('received_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('subtotal', sa.Numeric(12, 2), nullable=False),
sa.Column('tax_amount', sa.Numeric(12, 2), nullable=False, default=0.0),
sa.Column('shipping_cost', sa.Numeric(10, 2), nullable=False, default=0.0),
sa.Column('discount_amount', sa.Numeric(10, 2), nullable=False, default=0.0),
sa.Column('total_amount', sa.Numeric(12, 2), nullable=False),
sa.Column('currency', sa.String(3), nullable=False, default='EUR'),
sa.Column('paid_amount', sa.Numeric(12, 2), nullable=False, default=0.0),
sa.Column('payment_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('payment_reference', sa.String(100), nullable=True),
sa.Column('approved_by', UUID(as_uuid=True), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('invoice_document_url', sa.String(500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id']),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id'])
)
# Create indexes
op.create_index('ix_suppliers_tenant_id', 'suppliers', ['tenant_id'])
op.create_index('ix_suppliers_name', 'suppliers', ['name'])
op.create_index('ix_suppliers_tenant_name', 'suppliers', ['tenant_id', 'name'])
op.create_index('ix_suppliers_tenant_status', 'suppliers', ['tenant_id', 'status'])
op.create_index('ix_suppliers_tenant_type', 'suppliers', ['tenant_id', 'supplier_type'])
op.create_index('ix_suppliers_quality_rating', 'suppliers', ['quality_rating'])
op.create_index('ix_suppliers_status', 'suppliers', ['status'])
op.create_index('ix_suppliers_supplier_type', 'suppliers', ['supplier_type'])
op.create_index('ix_price_lists_tenant_id', 'supplier_price_lists', ['tenant_id'])
op.create_index('ix_price_lists_supplier_id', 'supplier_price_lists', ['supplier_id'])
op.create_index('ix_price_lists_tenant_supplier', 'supplier_price_lists', ['tenant_id', 'supplier_id'])
op.create_index('ix_price_lists_ingredient', 'supplier_price_lists', ['ingredient_id'])
op.create_index('ix_price_lists_active', 'supplier_price_lists', ['is_active'])
op.create_index('ix_price_lists_effective_date', 'supplier_price_lists', ['effective_date'])
op.create_index('ix_purchase_orders_tenant_id', 'purchase_orders', ['tenant_id'])
op.create_index('ix_purchase_orders_supplier_id', 'purchase_orders', ['supplier_id'])
op.create_index('ix_purchase_orders_tenant_supplier', 'purchase_orders', ['tenant_id', 'supplier_id'])
op.create_index('ix_purchase_orders_tenant_status', 'purchase_orders', ['tenant_id', 'status'])
op.create_index('ix_purchase_orders_po_number', 'purchase_orders', ['po_number'])
op.create_index('ix_purchase_orders_order_date', 'purchase_orders', ['order_date'])
op.create_index('ix_purchase_orders_delivery_date', 'purchase_orders', ['required_delivery_date'])
op.create_index('ix_purchase_orders_status', 'purchase_orders', ['status'])
op.create_index('ix_po_items_tenant_id', 'purchase_order_items', ['tenant_id'])
op.create_index('ix_po_items_purchase_order_id', 'purchase_order_items', ['purchase_order_id'])
op.create_index('ix_po_items_tenant_po', 'purchase_order_items', ['tenant_id', 'purchase_order_id'])
op.create_index('ix_po_items_ingredient', 'purchase_order_items', ['ingredient_id'])
op.create_index('ix_deliveries_tenant_id', 'deliveries', ['tenant_id'])
op.create_index('ix_deliveries_tenant_status', 'deliveries', ['tenant_id', 'status'])
op.create_index('ix_deliveries_scheduled_date', 'deliveries', ['scheduled_date'])
op.create_index('ix_deliveries_delivery_number', 'deliveries', ['delivery_number'])
op.create_index('ix_delivery_items_tenant_id', 'delivery_items', ['tenant_id'])
op.create_index('ix_delivery_items_delivery_id', 'delivery_items', ['delivery_id'])
op.create_index('ix_delivery_items_tenant_delivery', 'delivery_items', ['tenant_id', 'delivery_id'])
op.create_index('ix_delivery_items_ingredient', 'delivery_items', ['ingredient_id'])
op.create_index('ix_quality_reviews_tenant_id', 'supplier_quality_reviews', ['tenant_id'])
op.create_index('ix_quality_reviews_supplier_id', 'supplier_quality_reviews', ['supplier_id'])
op.create_index('ix_quality_reviews_tenant_supplier', 'supplier_quality_reviews', ['tenant_id', 'supplier_id'])
op.create_index('ix_quality_reviews_date', 'supplier_quality_reviews', ['review_date'])
op.create_index('ix_quality_reviews_overall_rating', 'supplier_quality_reviews', ['overall_rating'])
op.create_index('ix_invoices_tenant_id', 'supplier_invoices', ['tenant_id'])
op.create_index('ix_invoices_supplier_id', 'supplier_invoices', ['supplier_id'])
op.create_index('ix_invoices_tenant_supplier', 'supplier_invoices', ['tenant_id', 'supplier_id'])
op.create_index('ix_invoices_tenant_status', 'supplier_invoices', ['tenant_id', 'status'])
op.create_index('ix_invoices_due_date', 'supplier_invoices', ['due_date'])
op.create_index('ix_invoices_invoice_number', 'supplier_invoices', ['invoice_number'])
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_invoices_invoice_number', 'supplier_invoices')
op.drop_index('ix_invoices_due_date', 'supplier_invoices')
op.drop_index('ix_invoices_tenant_status', 'supplier_invoices')
op.drop_index('ix_invoices_tenant_supplier', 'supplier_invoices')
op.drop_index('ix_invoices_supplier_id', 'supplier_invoices')
op.drop_index('ix_invoices_tenant_id', 'supplier_invoices')
op.drop_index('ix_quality_reviews_overall_rating', 'supplier_quality_reviews')
op.drop_index('ix_quality_reviews_date', 'supplier_quality_reviews')
op.drop_index('ix_quality_reviews_tenant_supplier', 'supplier_quality_reviews')
op.drop_index('ix_quality_reviews_supplier_id', 'supplier_quality_reviews')
op.drop_index('ix_quality_reviews_tenant_id', 'supplier_quality_reviews')
op.drop_index('ix_delivery_items_ingredient', 'delivery_items')
op.drop_index('ix_delivery_items_tenant_delivery', 'delivery_items')
op.drop_index('ix_delivery_items_delivery_id', 'delivery_items')
op.drop_index('ix_delivery_items_tenant_id', 'delivery_items')
op.drop_index('ix_deliveries_delivery_number', 'deliveries')
op.drop_index('ix_deliveries_scheduled_date', 'deliveries')
op.drop_index('ix_deliveries_tenant_status', 'deliveries')
op.drop_index('ix_deliveries_tenant_id', 'deliveries')
op.drop_index('ix_po_items_ingredient', 'purchase_order_items')
op.drop_index('ix_po_items_tenant_po', 'purchase_order_items')
op.drop_index('ix_po_items_purchase_order_id', 'purchase_order_items')
op.drop_index('ix_po_items_tenant_id', 'purchase_order_items')
op.drop_index('ix_purchase_orders_status', 'purchase_orders')
op.drop_index('ix_purchase_orders_delivery_date', 'purchase_orders')
op.drop_index('ix_purchase_orders_order_date', 'purchase_orders')
op.drop_index('ix_purchase_orders_po_number', 'purchase_orders')
op.drop_index('ix_purchase_orders_tenant_status', 'purchase_orders')
op.drop_index('ix_purchase_orders_tenant_supplier', 'purchase_orders')
op.drop_index('ix_purchase_orders_supplier_id', 'purchase_orders')
op.drop_index('ix_purchase_orders_tenant_id', 'purchase_orders')
op.drop_index('ix_price_lists_effective_date', 'supplier_price_lists')
op.drop_index('ix_price_lists_active', 'supplier_price_lists')
op.drop_index('ix_price_lists_ingredient', 'supplier_price_lists')
op.drop_index('ix_price_lists_tenant_supplier', 'supplier_price_lists')
op.drop_index('ix_price_lists_supplier_id', 'supplier_price_lists')
op.drop_index('ix_price_lists_tenant_id', 'supplier_price_lists')
op.drop_index('ix_suppliers_supplier_type', 'suppliers')
op.drop_index('ix_suppliers_status', 'suppliers')
op.drop_index('ix_suppliers_quality_rating', 'suppliers')
op.drop_index('ix_suppliers_tenant_type', 'suppliers')
op.drop_index('ix_suppliers_tenant_status', 'suppliers')
op.drop_index('ix_suppliers_tenant_name', 'suppliers')
op.drop_index('ix_suppliers_name', 'suppliers')
op.drop_index('ix_suppliers_tenant_id', 'suppliers')
# Drop tables
op.drop_table('supplier_invoices')
op.drop_table('supplier_quality_reviews')
op.drop_table('delivery_items')
op.drop_table('deliveries')
op.drop_table('purchase_order_items')
op.drop_table('purchase_orders')
op.drop_table('supplier_price_lists')
op.drop_table('suppliers')
# Drop enums
op.execute('DROP TYPE IF EXISTS invoicestatus')
op.execute('DROP TYPE IF EXISTS deliveryrating')
op.execute('DROP TYPE IF EXISTS qualityrating')
op.execute('DROP TYPE IF EXISTS deliverystatus')
op.execute('DROP TYPE IF EXISTS purchaseorderstatus')
op.execute('DROP TYPE IF EXISTS paymentterms')
op.execute('DROP TYPE IF EXISTS supplierstatus')
op.execute('DROP TYPE IF EXISTS suppliertype')

View File

@@ -0,0 +1,41 @@
# services/suppliers/requirements.txt
# FastAPI and web framework
fastapi==0.104.1
uvicorn[standard]==0.24.0
# Database
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
asyncpg==0.29.0
aiosqlite==0.19.0
alembic==1.12.1
# Data processing
pandas==2.1.3
numpy==1.25.2
# HTTP clients
httpx==0.25.2
aiofiles==23.2.0
# Validation and serialization
pydantic==2.5.0
pydantic-settings==2.0.3
# Authentication and security
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
# Logging and monitoring
structlog==23.2.0
prometheus-client==0.19.0
# Message queues
aio-pika==9.3.1
# Additional for supplier management
python-dateutil==2.8.2
email-validator==2.1.0
# Development
python-multipart==0.0.6