Files
bakery-ia/services/procurement/app/services/purchase_order_service.py
2025-10-31 18:57:58 +01:00

681 lines
26 KiB
Python

# ================================================================
# services/procurement/app/services/purchase_order_service.py
# ================================================================
"""
Purchase Order Service - Business logic for purchase order management
Migrated from Suppliers Service to Procurement Service ownership
"""
import uuid
from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem, Delivery, DeliveryItem, SupplierInvoice
from app.repositories.purchase_order_repository import (
PurchaseOrderRepository,
PurchaseOrderItemRepository,
DeliveryRepository,
SupplierInvoiceRepository
)
from app.schemas.purchase_order_schemas import (
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderResponse,
DeliveryCreate,
DeliveryUpdate,
SupplierInvoiceCreate,
)
from app.core.config import settings
from shared.clients.suppliers_client import SuppliersServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class PurchaseOrderService:
"""Service for purchase order management operations"""
def __init__(
self,
db: AsyncSession,
config: BaseServiceSettings,
suppliers_client: Optional[SuppliersServiceClient] = None
):
self.db = db
self.config = config
self.po_repo = PurchaseOrderRepository(db)
self.item_repo = PurchaseOrderItemRepository(db)
self.delivery_repo = DeliveryRepository(db)
self.invoice_repo = SupplierInvoiceRepository(db)
# Initialize suppliers client for supplier validation
self.suppliers_client = suppliers_client or SuppliersServiceClient(config)
# ================================================================
# PURCHASE ORDER CRUD
# ================================================================
async def create_purchase_order(
self,
tenant_id: uuid.UUID,
po_data: PurchaseOrderCreate,
created_by: Optional[uuid.UUID] = None
) -> PurchaseOrder:
"""
Create a new purchase order with items
Flow:
1. Validate supplier exists and is active
2. Generate PO number
3. Calculate totals
4. Determine approval requirements
5. Create PO and items
6. Link to procurement plan if provided
"""
try:
logger.info("Creating purchase order",
tenant_id=tenant_id,
supplier_id=po_data.supplier_id)
# Validate supplier
supplier = await self._get_and_validate_supplier(tenant_id, po_data.supplier_id)
# Generate PO number
po_number = await self.po_repo.generate_po_number(tenant_id)
# Calculate totals
subtotal = po_data.subtotal
total_amount = (
subtotal +
po_data.tax_amount +
po_data.shipping_cost -
po_data.discount_amount
)
# Determine approval requirements
requires_approval = self._requires_approval(total_amount, po_data.priority)
initial_status = self._determine_initial_status(total_amount, requires_approval)
# Set delivery date if not provided
required_delivery_date = po_data.required_delivery_date
estimated_delivery_date = date.today() + timedelta(days=supplier.get('standard_lead_time', 7))
# Create PO
po_create_data = {
'tenant_id': tenant_id,
'supplier_id': po_data.supplier_id,
'po_number': po_number,
'status': initial_status,
'priority': po_data.priority,
'order_date': datetime.utcnow(),
'required_delivery_date': required_delivery_date,
'estimated_delivery_date': estimated_delivery_date,
'subtotal': subtotal,
'tax_amount': po_data.tax_amount,
'shipping_cost': po_data.shipping_cost,
'discount_amount': po_data.discount_amount,
'total_amount': total_amount,
'currency': supplier.get('currency', 'EUR'),
'requires_approval': requires_approval,
'notes': po_data.notes,
'procurement_plan_id': po_data.procurement_plan_id,
'created_by': created_by,
'updated_by': created_by,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
purchase_order = await self.po_repo.create_po(po_create_data)
# Create PO items
for item_data in po_data.items:
item_create_data = {
'tenant_id': tenant_id,
'purchase_order_id': purchase_order.id,
'inventory_product_id': item_data.inventory_product_id,
'ordered_quantity': item_data.ordered_quantity,
'unit_price': item_data.unit_price,
'unit_of_measure': item_data.unit_of_measure,
'line_total': item_data.ordered_quantity * item_data.unit_price,
'received_quantity': Decimal('0'),
'quality_requirements': item_data.quality_requirements,
'item_notes': item_data.item_notes,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
await self.item_repo.create_item(item_create_data)
await self.db.commit()
logger.info("Purchase order created successfully",
tenant_id=tenant_id,
po_id=purchase_order.id,
po_number=po_number,
total_amount=float(total_amount))
return purchase_order
except Exception as e:
await self.db.rollback()
logger.error("Error creating purchase order", error=str(e), tenant_id=tenant_id)
raise
async def get_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID
) -> Optional[PurchaseOrder]:
"""Get purchase order by ID with items"""
try:
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if po:
# Enrich with supplier information
await self._enrich_po_with_supplier(tenant_id, po)
return po
except Exception as e:
logger.error("Error getting purchase order", error=str(e), po_id=po_id)
return None
async def list_purchase_orders(
self,
tenant_id: uuid.UUID,
skip: int = 0,
limit: int = 50,
supplier_id: Optional[uuid.UUID] = None,
status: Optional[str] = None
) -> List[PurchaseOrder]:
"""List purchase orders with filters"""
try:
# Convert status string to enum if provided
status_enum = None
if status:
try:
from app.models.purchase_order import PurchaseOrderStatus
# Convert from UPPERCASE to lowercase for enum lookup
status_enum = PurchaseOrderStatus[status.lower()]
except (KeyError, AttributeError):
logger.warning("Invalid status value provided", status=status)
status_enum = None
pos = await self.po_repo.list_purchase_orders(
tenant_id=tenant_id,
offset=skip, # Repository uses 'offset' parameter
limit=limit,
supplier_id=supplier_id,
status=status_enum
)
# Enrich with supplier information
for po in pos:
await self._enrich_po_with_supplier(tenant_id, po)
return pos
except Exception as e:
logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id)
return []
async def update_po(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
po_data: PurchaseOrderUpdate,
updated_by: Optional[uuid.UUID] = None
) -> Optional[PurchaseOrder]:
"""Update purchase order information"""
try:
logger.info("Updating purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
# Check if order can be modified
if po.status in ['completed', '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']):
total_amount = (
po.subtotal +
update_data.get('tax_amount', po.tax_amount) +
update_data.get('shipping_cost', po.shipping_cost) -
update_data.get('discount_amount', po.discount_amount)
)
update_data['total_amount'] = total_amount
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order updated successfully", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error updating purchase order", error=str(e), po_id=po_id)
raise
async def update_order_status(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
status: str,
updated_by: Optional[uuid.UUID] = None,
notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Update purchase order status"""
try:
logger.info("Updating PO status", po_id=po_id, status=status)
po = await self.po_repo.get_po_by_id(po_id, tenant_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} to {status}")
update_data = {
'status': status,
'updated_by': updated_by,
'updated_at': datetime.utcnow()
}
if status == 'sent_to_supplier':
update_data['sent_to_supplier_at'] = datetime.utcnow()
elif status == 'confirmed':
update_data['supplier_confirmation_date'] = datetime.utcnow()
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
return po
except Exception as e:
await self.db.rollback()
logger.error("Error updating PO status", error=str(e), po_id=po_id)
raise
async def approve_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
approved_by: uuid.UUID,
approval_notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Approve a purchase order"""
try:
logger.info("Approving purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
if po.status not in ['draft', 'pending_approval']:
raise ValueError(f"Cannot approve order with status {po.status}")
update_data = {
'status': 'approved',
'approved_by': approved_by,
'approved_at': datetime.utcnow(),
'updated_by': approved_by,
'updated_at': datetime.utcnow()
}
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order approved successfully", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error approving purchase order", error=str(e), po_id=po_id)
raise
async def reject_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
rejected_by: uuid.UUID,
rejection_reason: str
) -> Optional[PurchaseOrder]:
"""Reject a purchase order"""
try:
logger.info("Rejecting purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
if po.status not in ['draft', 'pending_approval']:
raise ValueError(f"Cannot reject order with status {po.status}")
update_data = {
'status': 'rejected',
'rejection_reason': rejection_reason,
'updated_by': rejected_by,
'updated_at': datetime.utcnow()
}
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order rejected", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error rejecting purchase order", error=str(e), po_id=po_id)
raise
async def cancel_purchase_order(
self,
tenant_id: uuid.UUID,
po_id: uuid.UUID,
cancelled_by: uuid.UUID,
cancellation_reason: str
) -> Optional[PurchaseOrder]:
"""Cancel a purchase order"""
try:
logger.info("Cancelling purchase order", po_id=po_id)
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
if not po:
return None
if po.status in ['completed', 'cancelled']:
raise ValueError(f"Cannot cancel order with status {po.status}")
update_data = {
'status': 'cancelled',
'notes': f"{po.notes or ''}\nCancellation: {cancellation_reason}",
'updated_by': cancelled_by,
'updated_at': datetime.utcnow()
}
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
await self.db.commit()
logger.info("Purchase order cancelled", po_id=po_id)
return po
except Exception as e:
await self.db.rollback()
logger.error("Error cancelling purchase order", error=str(e), po_id=po_id)
raise
# ================================================================
# DELIVERY MANAGEMENT
# ================================================================
async def create_delivery(
self,
tenant_id: uuid.UUID,
delivery_data: DeliveryCreate,
created_by: uuid.UUID
) -> Delivery:
"""Create a delivery record for a purchase order"""
try:
logger.info("Creating delivery", tenant_id=tenant_id, po_id=delivery_data.purchase_order_id)
# Validate PO exists
po = await self.po_repo.get_po_by_id(delivery_data.purchase_order_id, tenant_id)
if not po:
raise ValueError("Purchase order not found")
# Generate delivery number
delivery_number = await self.delivery_repo.generate_delivery_number(tenant_id)
# Create delivery
delivery_create_data = {
'tenant_id': tenant_id,
'purchase_order_id': delivery_data.purchase_order_id,
'supplier_id': delivery_data.supplier_id,
'delivery_number': delivery_number,
'supplier_delivery_note': delivery_data.supplier_delivery_note,
'status': 'scheduled',
'scheduled_date': delivery_data.scheduled_date,
'estimated_arrival': delivery_data.estimated_arrival,
'carrier_name': delivery_data.carrier_name,
'tracking_number': delivery_data.tracking_number,
'notes': delivery_data.notes,
'created_by': created_by,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
delivery = await self.delivery_repo.create_delivery(delivery_create_data)
# Create delivery items
for item_data in delivery_data.items:
item_create_data = {
'tenant_id': tenant_id,
'delivery_id': delivery.id,
'purchase_order_item_id': item_data.purchase_order_item_id,
'inventory_product_id': item_data.inventory_product_id,
'ordered_quantity': item_data.ordered_quantity,
'delivered_quantity': item_data.delivered_quantity,
'accepted_quantity': item_data.accepted_quantity,
'rejected_quantity': item_data.rejected_quantity,
'batch_lot_number': item_data.batch_lot_number,
'expiry_date': item_data.expiry_date,
'quality_grade': item_data.quality_grade,
'quality_issues': item_data.quality_issues,
'rejection_reason': item_data.rejection_reason,
'item_notes': item_data.item_notes,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
await self.delivery_repo.create_delivery_item(item_create_data)
await self.db.commit()
logger.info("Delivery created successfully",
tenant_id=tenant_id,
delivery_id=delivery.id,
delivery_number=delivery_number)
return delivery
except Exception as e:
await self.db.rollback()
logger.error("Error creating delivery", error=str(e), tenant_id=tenant_id)
raise
async def update_delivery_status(
self,
tenant_id: uuid.UUID,
delivery_id: uuid.UUID,
status: str,
updated_by: uuid.UUID
) -> Optional[Delivery]:
"""Update delivery status"""
try:
update_data = {
'status': status,
'updated_at': datetime.utcnow()
}
if status == 'in_transit':
update_data['actual_arrival'] = None
elif status == 'delivered':
update_data['actual_arrival'] = datetime.utcnow()
elif status == 'completed':
update_data['completed_at'] = datetime.utcnow()
delivery = await self.delivery_repo.update_delivery(delivery_id, tenant_id, update_data)
await self.db.commit()
return delivery
except Exception as e:
await self.db.rollback()
logger.error("Error updating delivery status", error=str(e), delivery_id=delivery_id)
raise
# ================================================================
# INVOICE MANAGEMENT
# ================================================================
async def create_invoice(
self,
tenant_id: uuid.UUID,
invoice_data: SupplierInvoiceCreate,
created_by: uuid.UUID
) -> SupplierInvoice:
"""Create a supplier invoice"""
try:
logger.info("Creating supplier invoice", tenant_id=tenant_id)
# Calculate total
total_amount = (
invoice_data.subtotal +
invoice_data.tax_amount +
invoice_data.shipping_cost -
invoice_data.discount_amount
)
# Get PO for currency
po = await self.po_repo.get_po_by_id(invoice_data.purchase_order_id, tenant_id)
if not po:
raise ValueError("Purchase order not found")
invoice_create_data = {
'tenant_id': tenant_id,
'purchase_order_id': invoice_data.purchase_order_id,
'supplier_id': invoice_data.supplier_id,
'invoice_number': invoice_data.invoice_number,
'status': 'received',
'invoice_date': invoice_data.invoice_date,
'due_date': invoice_data.due_date,
'subtotal': invoice_data.subtotal,
'tax_amount': invoice_data.tax_amount,
'shipping_cost': invoice_data.shipping_cost,
'discount_amount': invoice_data.discount_amount,
'total_amount': total_amount,
'currency': po.currency,
'paid_amount': Decimal('0'),
'remaining_amount': total_amount,
'notes': invoice_data.notes,
'payment_reference': invoice_data.payment_reference,
'created_by': created_by,
'updated_by': created_by,
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow(),
}
invoice = await self.invoice_repo.create_invoice(invoice_create_data)
await self.db.commit()
logger.info("Supplier invoice created", invoice_id=invoice.id)
return invoice
except Exception as e:
await self.db.rollback()
logger.error("Error creating invoice", error=str(e), tenant_id=tenant_id)
raise
# ================================================================
# PRIVATE HELPER METHODS
# ================================================================
async def _get_and_validate_supplier(self, tenant_id: uuid.UUID, supplier_id: uuid.UUID) -> Dict[str, Any]:
"""Get and validate supplier from Suppliers Service"""
try:
supplier = await self.suppliers_client.get_supplier(str(tenant_id), str(supplier_id))
if not supplier:
raise ValueError("Supplier not found")
if supplier.get('status') != 'active':
raise ValueError("Cannot create orders for inactive suppliers")
return supplier
except Exception as e:
logger.error("Error validating supplier", error=str(e), supplier_id=supplier_id)
raise
async def _enrich_po_with_supplier(self, tenant_id: uuid.UUID, po: PurchaseOrder) -> None:
"""Enrich purchase order with supplier information"""
try:
supplier = await self.suppliers_client.get_supplier(str(tenant_id), str(po.supplier_id))
if supplier:
# Set supplier_name as a dynamic attribute on the model instance
po.supplier_name = supplier.get('name', 'Unknown Supplier')
# Create a supplier summary object with the required fields for the frontend
# Using the same structure as the suppliers service SupplierSummary schema
supplier_summary = {
'id': supplier.get('id'),
'name': supplier.get('name', 'Unknown Supplier'),
'supplier_code': supplier.get('supplier_code'),
'email': supplier.get('email'),
'phone': supplier.get('phone'),
'contact_person': supplier.get('contact_person'),
'address_line1': supplier.get('address_line1'),
'city': supplier.get('city'),
'country': supplier.get('country'),
'supplier_type': supplier.get('supplier_type', 'raw_material'),
'status': supplier.get('status', 'active'),
'mobile': supplier.get('mobile'),
'website': supplier.get('website'),
'payment_terms': supplier.get('payment_terms', 'NET_30'),
'standard_lead_time': supplier.get('standard_lead_time', 3),
'quality_rating': supplier.get('quality_rating'),
'delivery_rating': supplier.get('delivery_rating'),
'total_orders': supplier.get('total_orders', 0),
'total_amount': supplier.get('total_amount', 0)
}
# Set the full supplier object as a dynamic attribute
po.supplier = supplier_summary
except Exception as e:
logger.warning("Failed to enrich PO with supplier info", error=str(e), po_id=po.id, supplier_id=po.supplier_id)
po.supplier_name = None
po.supplier = None
def _requires_approval(self, total_amount: Decimal, priority: str) -> bool:
"""Determine if PO requires approval"""
manager_threshold = Decimal(str(getattr(settings, 'MANAGER_APPROVAL_THRESHOLD', 1000)))
return total_amount >= manager_threshold or priority == 'critical'
def _determine_initial_status(self, total_amount: Decimal, requires_approval: bool) -> str:
"""Determine initial PO status"""
auto_approve_threshold = Decimal(str(getattr(settings, 'AUTO_APPROVE_THRESHOLD', 100)))
if requires_approval:
return 'pending_approval'
elif total_amount <= auto_approve_threshold:
return 'approved'
else:
return 'draft'
def _is_valid_status_transition(self, from_status: str, to_status: str) -> bool:
"""Validate status transition"""
valid_transitions = {
'draft': ['pending_approval', 'approved', 'cancelled'],
'pending_approval': ['approved', 'rejected', 'cancelled'],
'approved': ['sent_to_supplier', 'cancelled'],
'sent_to_supplier': ['confirmed', 'cancelled'],
'confirmed': ['in_production', 'cancelled'],
'in_production': ['shipped', 'cancelled'],
'shipped': ['delivered', 'cancelled'],
'delivered': ['completed'],
'rejected': [],
'cancelled': [],
'completed': []
}
return to_status in valid_transitions.get(from_status, [])