681 lines
26 KiB
Python
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, [])
|