Files
bakery-ia/services/suppliers/app/services/purchase_order_service.py
2025-08-14 16:47:34 +02:00

467 lines
16 KiB
Python

# 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_inventory_product_purchase_history(
self,
tenant_id: UUID,
inventory_product_id: UUID,
days_back: int = 90
) -> Dict[str, Any]:
"""Get purchase history for an inventory product"""
return self.item_repository.get_inventory_product_purchase_history(
tenant_id, inventory_product_id, days_back
)
async def get_top_purchased_inventory_products(
self,
tenant_id: UUID,
days_back: int = 30,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get most purchased inventory products"""
return self.item_repository.get_top_purchased_inventory_products(
tenant_id, days_back, limit
)