Files
bakery-ia/services/suppliers/app/services/delivery_service.py
2025-08-13 17:39:35 +02:00

355 lines
14 KiB
Python

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