Create new services: inventory, recipes, suppliers
This commit is contained in:
467
services/suppliers/app/services/purchase_order_service.py
Normal file
467
services/suppliers/app/services/purchase_order_service.py
Normal file
@@ -0,0 +1,467 @@
|
||||
# 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_ingredient_purchase_history(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
ingredient_id: UUID,
|
||||
days_back: int = 90
|
||||
) -> Dict[str, Any]:
|
||||
"""Get purchase history for an ingredient"""
|
||||
return self.item_repository.get_ingredient_purchase_history(
|
||||
tenant_id, ingredient_id, days_back
|
||||
)
|
||||
|
||||
async def get_top_purchased_ingredients(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30,
|
||||
limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get most purchased ingredients"""
|
||||
return self.item_repository.get_top_purchased_ingredients(
|
||||
tenant_id, days_back, limit
|
||||
)
|
||||
Reference in New Issue
Block a user