467 lines
16 KiB
Python
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
|
|
) |