# 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 await 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" ) # Send email to supplier if requested if send_email: try: supplier = self.supplier_repository.get_by_id(po.supplier_id) if supplier and supplier.email: from shared.clients.notification_client import create_notification_client notification_client = create_notification_client(settings) # Prepare email content subject = f"Purchase Order {po.po_number} from {po.tenant_id}" message = f""" Dear {supplier.name}, We are sending you Purchase Order #{po.po_number}. Order Details: - PO Number: {po.po_number} - Expected Delivery: {po.expected_delivery_date} - Total Amount: €{po.total_amount} Please confirm receipt of this purchase order. Best regards """ await notification_client.send_email( tenant_id=str(po.tenant_id), to_email=supplier.email, subject=subject, message=message, priority="normal" ) logger.info("Email sent to supplier", po_id=str(po_id), supplier_email=supplier.email) else: logger.warning("Supplier email not available", po_id=str(po_id), supplier_id=str(po.supplier_id)) except Exception as e: logger.error("Failed to send email to supplier", error=str(e), po_id=str(po_id)) # Don't fail the entire operation if email fails 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 await 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 ) async def delete_purchase_order(self, po_id: UUID) -> bool: """ Delete (soft delete) a purchase order Only allows deletion of draft orders """ logger.info("Deleting purchase order", po_id=str(po_id)) po = self.repository.get_by_id(po_id) if not po: return False # Only allow deletion of draft orders if po.status not in [PurchaseOrderStatus.DRAFT, PurchaseOrderStatus.CANCELLED]: raise ValueError( f"Cannot delete purchase order with status {po.status.value}. " "Only draft and cancelled orders can be deleted." ) # Perform soft delete try: self.repository.delete(po_id) self.db.commit() logger.info("Purchase order deleted successfully", po_id=str(po_id)) return True except Exception as e: self.db.rollback() logger.error("Failed to delete purchase order", po_id=str(po_id), error=str(e)) raise