355 lines
14 KiB
Python
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
|
|
} |