Create new services: inventory, recipes, suppliers
This commit is contained in:
1
services/suppliers/app/services/__init__.py
Normal file
1
services/suppliers/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services/suppliers/app/services/__init__.py
|
||||
355
services/suppliers/app/services/delivery_service.py
Normal file
355
services/suppliers/app/services/delivery_service.py
Normal file
@@ -0,0 +1,355 @@
|
||||
# services/suppliers/app/services/delivery_service.py
|
||||
"""
|
||||
Delivery service for business logic operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.repositories.delivery_repository import DeliveryRepository
|
||||
from app.repositories.purchase_order_repository import PurchaseOrderRepository
|
||||
from app.repositories.supplier_repository import SupplierRepository
|
||||
from app.models.suppliers import (
|
||||
Delivery, DeliveryItem, DeliveryStatus,
|
||||
PurchaseOrder, PurchaseOrderStatus, SupplierStatus
|
||||
)
|
||||
from app.schemas.suppliers import (
|
||||
DeliveryCreate, DeliveryUpdate, DeliverySearchParams
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class DeliveryService:
|
||||
"""Service for delivery management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.repository = DeliveryRepository(db)
|
||||
self.po_repository = PurchaseOrderRepository(db)
|
||||
self.supplier_repository = SupplierRepository(db)
|
||||
|
||||
async def create_delivery(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
delivery_data: DeliveryCreate,
|
||||
created_by: UUID
|
||||
) -> Delivery:
|
||||
"""Create a new delivery"""
|
||||
logger.info(
|
||||
"Creating delivery",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=str(delivery_data.purchase_order_id)
|
||||
)
|
||||
|
||||
# Validate purchase order exists and belongs to tenant
|
||||
po = self.po_repository.get_by_id(delivery_data.purchase_order_id)
|
||||
if not po:
|
||||
raise ValueError("Purchase order not found")
|
||||
if po.tenant_id != tenant_id:
|
||||
raise ValueError("Purchase order does not belong to this tenant")
|
||||
if po.status not in [
|
||||
PurchaseOrderStatus.CONFIRMED,
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED
|
||||
]:
|
||||
raise ValueError("Purchase order must be confirmed before creating deliveries")
|
||||
|
||||
# Validate supplier
|
||||
supplier = self.supplier_repository.get_by_id(delivery_data.supplier_id)
|
||||
if not supplier:
|
||||
raise ValueError("Supplier not found")
|
||||
if supplier.id != po.supplier_id:
|
||||
raise ValueError("Supplier does not match purchase order supplier")
|
||||
|
||||
# Generate delivery number
|
||||
delivery_number = self.repository.generate_delivery_number(tenant_id)
|
||||
|
||||
# Create delivery
|
||||
delivery_create_data = delivery_data.model_dump(exclude={'items'})
|
||||
delivery_create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'delivery_number': delivery_number,
|
||||
'status': DeliveryStatus.SCHEDULED,
|
||||
'created_by': created_by
|
||||
})
|
||||
|
||||
# Set default scheduled date if not provided
|
||||
if not delivery_create_data.get('scheduled_date'):
|
||||
delivery_create_data['scheduled_date'] = datetime.utcnow()
|
||||
|
||||
delivery = self.repository.create(delivery_create_data)
|
||||
|
||||
# Create delivery items
|
||||
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
|
||||
item_repo = PurchaseOrderItemRepository(self.db)
|
||||
|
||||
for item_data in delivery_data.items:
|
||||
# Validate purchase order item
|
||||
po_item = item_repo.get_by_id(item_data.purchase_order_item_id)
|
||||
if not po_item or po_item.purchase_order_id != po.id:
|
||||
raise ValueError("Invalid purchase order item")
|
||||
|
||||
# Create delivery item
|
||||
from app.models.suppliers import DeliveryItem
|
||||
item_create_data = item_data.model_dump()
|
||||
item_create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'delivery_id': delivery.id
|
||||
})
|
||||
|
||||
delivery_item = DeliveryItem(**item_create_data)
|
||||
self.db.add(delivery_item)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
logger.info(
|
||||
"Delivery created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
delivery_id=str(delivery.id),
|
||||
delivery_number=delivery_number
|
||||
)
|
||||
|
||||
return delivery
|
||||
|
||||
async def get_delivery(self, delivery_id: UUID) -> Optional[Delivery]:
|
||||
"""Get delivery by ID with items"""
|
||||
return self.repository.get_with_items(delivery_id)
|
||||
|
||||
async def update_delivery(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
delivery_data: DeliveryUpdate,
|
||||
updated_by: UUID
|
||||
) -> Optional[Delivery]:
|
||||
"""Update delivery information"""
|
||||
logger.info("Updating delivery", delivery_id=str(delivery_id))
|
||||
|
||||
delivery = self.repository.get_by_id(delivery_id)
|
||||
if not delivery:
|
||||
return None
|
||||
|
||||
# Check if delivery can be modified
|
||||
if delivery.status in [DeliveryStatus.DELIVERED, DeliveryStatus.FAILED_DELIVERY]:
|
||||
raise ValueError("Cannot modify completed deliveries")
|
||||
|
||||
# Prepare update data
|
||||
update_data = delivery_data.model_dump(exclude_unset=True)
|
||||
update_data['created_by'] = updated_by # Track who updated
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
|
||||
delivery = self.repository.update(delivery_id, update_data)
|
||||
|
||||
logger.info("Delivery updated successfully", delivery_id=str(delivery_id))
|
||||
return delivery
|
||||
|
||||
async def update_delivery_status(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
status: DeliveryStatus,
|
||||
updated_by: UUID,
|
||||
notes: Optional[str] = None,
|
||||
update_timestamps: bool = True
|
||||
) -> Optional[Delivery]:
|
||||
"""Update delivery status"""
|
||||
logger.info("Updating delivery status", delivery_id=str(delivery_id), status=status.value)
|
||||
|
||||
return self.repository.update_delivery_status(
|
||||
delivery_id=delivery_id,
|
||||
status=status,
|
||||
updated_by=updated_by,
|
||||
notes=notes,
|
||||
update_timestamps=update_timestamps
|
||||
)
|
||||
|
||||
async def mark_as_received(
|
||||
self,
|
||||
delivery_id: UUID,
|
||||
received_by: UUID,
|
||||
inspection_passed: bool = True,
|
||||
inspection_notes: Optional[str] = None,
|
||||
quality_issues: Optional[Dict[str, Any]] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[Delivery]:
|
||||
"""Mark delivery as received with inspection details"""
|
||||
logger.info("Marking delivery as received", delivery_id=str(delivery_id))
|
||||
|
||||
delivery = self.repository.mark_as_received(
|
||||
delivery_id=delivery_id,
|
||||
received_by=received_by,
|
||||
inspection_passed=inspection_passed,
|
||||
inspection_notes=inspection_notes,
|
||||
quality_issues=quality_issues
|
||||
)
|
||||
|
||||
if not delivery:
|
||||
return None
|
||||
|
||||
# Add custom notes if provided
|
||||
if notes:
|
||||
existing_notes = delivery.notes or ""
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
delivery.notes = f"{existing_notes}\n[{timestamp}] Receipt notes: {notes}".strip()
|
||||
self.repository.update(delivery_id, {'notes': delivery.notes})
|
||||
|
||||
# Update purchase order item received quantities
|
||||
await self._update_purchase_order_received_quantities(delivery)
|
||||
|
||||
# Check if purchase order is fully received
|
||||
await self._check_purchase_order_completion(delivery.purchase_order_id)
|
||||
|
||||
logger.info("Delivery marked as received", delivery_id=str(delivery_id))
|
||||
return delivery
|
||||
|
||||
async def search_deliveries(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_params: DeliverySearchParams
|
||||
) -> List[Delivery]:
|
||||
"""Search deliveries with filters"""
|
||||
return self.repository.search_deliveries(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=search_params.supplier_id,
|
||||
status=search_params.status,
|
||||
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_deliveries_by_purchase_order(self, po_id: UUID) -> List[Delivery]:
|
||||
"""Get all deliveries for a purchase order"""
|
||||
return self.repository.get_by_purchase_order(po_id)
|
||||
|
||||
async def get_todays_deliveries(self, tenant_id: UUID) -> List[Delivery]:
|
||||
"""Get deliveries scheduled for today"""
|
||||
return self.repository.get_todays_deliveries(tenant_id)
|
||||
|
||||
async def get_overdue_deliveries(self, tenant_id: UUID) -> List[Delivery]:
|
||||
"""Get overdue deliveries"""
|
||||
return self.repository.get_overdue_deliveries(tenant_id)
|
||||
|
||||
async def get_scheduled_deliveries(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None
|
||||
) -> List[Delivery]:
|
||||
"""Get scheduled deliveries for date range"""
|
||||
return self.repository.get_scheduled_deliveries(tenant_id, date_from, date_to)
|
||||
|
||||
async def get_delivery_performance_stats(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30,
|
||||
supplier_id: Optional[UUID] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Get delivery performance statistics"""
|
||||
return self.repository.get_delivery_performance_stats(
|
||||
tenant_id, days_back, supplier_id
|
||||
)
|
||||
|
||||
async def get_upcoming_deliveries_summary(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get summary of upcoming deliveries"""
|
||||
return self.repository.get_upcoming_deliveries_summary(tenant_id)
|
||||
|
||||
async def _update_purchase_order_received_quantities(self, delivery: Delivery):
|
||||
"""Update purchase order item received quantities based on delivery"""
|
||||
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
|
||||
|
||||
item_repo = PurchaseOrderItemRepository(self.db)
|
||||
|
||||
# Get delivery items with accepted quantities
|
||||
delivery_with_items = self.repository.get_with_items(delivery.id)
|
||||
if not delivery_with_items or not delivery_with_items.items:
|
||||
return
|
||||
|
||||
for delivery_item in delivery_with_items.items:
|
||||
# Update purchase order item received quantity
|
||||
item_repo.add_received_quantity(
|
||||
delivery_item.purchase_order_item_id,
|
||||
delivery_item.accepted_quantity
|
||||
)
|
||||
|
||||
async def _check_purchase_order_completion(self, po_id: UUID):
|
||||
"""Check if purchase order is fully received and update status"""
|
||||
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
|
||||
|
||||
item_repo = PurchaseOrderItemRepository(self.db)
|
||||
po_items = item_repo.get_by_purchase_order(po_id)
|
||||
|
||||
if not po_items:
|
||||
return
|
||||
|
||||
# Check if all items are fully received
|
||||
fully_received = all(item.remaining_quantity == 0 for item in po_items)
|
||||
partially_received = any(item.received_quantity > 0 for item in po_items)
|
||||
|
||||
if fully_received:
|
||||
# Mark purchase order as completed
|
||||
self.po_repository.update_order_status(
|
||||
po_id,
|
||||
PurchaseOrderStatus.COMPLETED,
|
||||
po_items[0].tenant_id, # Use tenant_id as updated_by placeholder
|
||||
"All items received"
|
||||
)
|
||||
elif partially_received:
|
||||
# Mark as partially received if not already
|
||||
po = self.po_repository.get_by_id(po_id)
|
||||
if po and po.status == PurchaseOrderStatus.CONFIRMED:
|
||||
self.po_repository.update_order_status(
|
||||
po_id,
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED,
|
||||
po.tenant_id, # Use tenant_id as updated_by placeholder
|
||||
"Partial delivery received"
|
||||
)
|
||||
|
||||
async def generate_delivery_tracking_info(self, delivery_id: UUID) -> Dict[str, Any]:
|
||||
"""Generate delivery tracking information"""
|
||||
delivery = self.repository.get_with_items(delivery_id)
|
||||
if not delivery:
|
||||
return {}
|
||||
|
||||
# Calculate delivery metrics
|
||||
total_items = len(delivery.items) if delivery.items else 0
|
||||
delivered_items = sum(
|
||||
1 for item in (delivery.items or [])
|
||||
if item.delivered_quantity > 0
|
||||
)
|
||||
accepted_items = sum(
|
||||
1 for item in (delivery.items or [])
|
||||
if item.accepted_quantity > 0
|
||||
)
|
||||
rejected_items = sum(
|
||||
1 for item in (delivery.items or [])
|
||||
if item.rejected_quantity > 0
|
||||
)
|
||||
|
||||
# Calculate timing metrics
|
||||
on_time = False
|
||||
delay_hours = 0
|
||||
if delivery.scheduled_date and delivery.actual_arrival:
|
||||
delay_seconds = (delivery.actual_arrival - delivery.scheduled_date).total_seconds()
|
||||
delay_hours = delay_seconds / 3600
|
||||
on_time = delay_hours <= 0
|
||||
|
||||
return {
|
||||
"delivery_id": str(delivery.id),
|
||||
"delivery_number": delivery.delivery_number,
|
||||
"status": delivery.status.value,
|
||||
"total_items": total_items,
|
||||
"delivered_items": delivered_items,
|
||||
"accepted_items": accepted_items,
|
||||
"rejected_items": rejected_items,
|
||||
"inspection_passed": delivery.inspection_passed,
|
||||
"on_time": on_time,
|
||||
"delay_hours": round(delay_hours, 1) if delay_hours > 0 else 0,
|
||||
"quality_issues": delivery.quality_issues or {},
|
||||
"scheduled_date": delivery.scheduled_date.isoformat() if delivery.scheduled_date else None,
|
||||
"actual_arrival": delivery.actual_arrival.isoformat() if delivery.actual_arrival else None,
|
||||
"completed_at": delivery.completed_at.isoformat() if delivery.completed_at else None
|
||||
}
|
||||
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
|
||||
)
|
||||
321
services/suppliers/app/services/supplier_service.py
Normal file
321
services/suppliers/app/services/supplier_service.py
Normal file
@@ -0,0 +1,321 @@
|
||||
# services/suppliers/app/services/supplier_service.py
|
||||
"""
|
||||
Supplier service for business logic operations
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.repositories.supplier_repository import SupplierRepository
|
||||
from app.models.suppliers import Supplier, SupplierStatus, SupplierType
|
||||
from app.schemas.suppliers import (
|
||||
SupplierCreate, SupplierUpdate, SupplierResponse,
|
||||
SupplierSearchParams, SupplierStatistics
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SupplierService:
|
||||
"""Service for supplier management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.repository = SupplierRepository(db)
|
||||
|
||||
async def create_supplier(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_data: SupplierCreate,
|
||||
created_by: UUID
|
||||
) -> Supplier:
|
||||
"""Create a new supplier"""
|
||||
logger.info("Creating supplier", tenant_id=str(tenant_id), name=supplier_data.name)
|
||||
|
||||
# Check for duplicate name
|
||||
existing = self.repository.get_by_name(tenant_id, supplier_data.name)
|
||||
if existing:
|
||||
raise ValueError(f"Supplier with name '{supplier_data.name}' already exists")
|
||||
|
||||
# Check for duplicate supplier code if provided
|
||||
if supplier_data.supplier_code:
|
||||
existing_code = self.repository.get_by_supplier_code(
|
||||
tenant_id, supplier_data.supplier_code
|
||||
)
|
||||
if existing_code:
|
||||
raise ValueError(
|
||||
f"Supplier with code '{supplier_data.supplier_code}' already exists"
|
||||
)
|
||||
|
||||
# Generate supplier code if not provided
|
||||
supplier_code = supplier_data.supplier_code
|
||||
if not supplier_code:
|
||||
supplier_code = self._generate_supplier_code(supplier_data.name)
|
||||
|
||||
# Create supplier data
|
||||
create_data = supplier_data.model_dump(exclude_unset=True)
|
||||
create_data.update({
|
||||
'tenant_id': tenant_id,
|
||||
'supplier_code': supplier_code,
|
||||
'status': SupplierStatus.PENDING_APPROVAL,
|
||||
'created_by': created_by,
|
||||
'updated_by': created_by,
|
||||
'quality_rating': 0.0,
|
||||
'delivery_rating': 0.0,
|
||||
'total_orders': 0,
|
||||
'total_amount': 0.0
|
||||
})
|
||||
|
||||
supplier = self.repository.create(create_data)
|
||||
|
||||
logger.info(
|
||||
"Supplier created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(supplier.id),
|
||||
name=supplier.name
|
||||
)
|
||||
|
||||
return supplier
|
||||
|
||||
async def get_supplier(self, supplier_id: UUID) -> Optional[Supplier]:
|
||||
"""Get supplier by ID"""
|
||||
return self.repository.get_by_id(supplier_id)
|
||||
|
||||
async def update_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
supplier_data: SupplierUpdate,
|
||||
updated_by: UUID
|
||||
) -> Optional[Supplier]:
|
||||
"""Update supplier information"""
|
||||
logger.info("Updating supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.get_by_id(supplier_id)
|
||||
if not supplier:
|
||||
return None
|
||||
|
||||
# Check for duplicate name if changing
|
||||
if supplier_data.name and supplier_data.name != supplier.name:
|
||||
existing = self.repository.get_by_name(supplier.tenant_id, supplier_data.name)
|
||||
if existing:
|
||||
raise ValueError(f"Supplier with name '{supplier_data.name}' already exists")
|
||||
|
||||
# Check for duplicate supplier code if changing
|
||||
if (supplier_data.supplier_code and
|
||||
supplier_data.supplier_code != supplier.supplier_code):
|
||||
existing_code = self.repository.get_by_supplier_code(
|
||||
supplier.tenant_id, supplier_data.supplier_code
|
||||
)
|
||||
if existing_code:
|
||||
raise ValueError(
|
||||
f"Supplier with code '{supplier_data.supplier_code}' already exists"
|
||||
)
|
||||
|
||||
# Prepare update data
|
||||
update_data = supplier_data.model_dump(exclude_unset=True)
|
||||
update_data['updated_by'] = updated_by
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
|
||||
supplier = self.repository.update(supplier_id, update_data)
|
||||
|
||||
logger.info("Supplier updated successfully", supplier_id=str(supplier_id))
|
||||
return supplier
|
||||
|
||||
async def delete_supplier(self, supplier_id: UUID) -> bool:
|
||||
"""Delete supplier (soft delete by changing status)"""
|
||||
logger.info("Deleting supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.get_by_id(supplier_id)
|
||||
if not supplier:
|
||||
return False
|
||||
|
||||
# Check if supplier has active purchase orders
|
||||
# TODO: Add check for active purchase orders once PO service is implemented
|
||||
|
||||
# Soft delete by changing status
|
||||
self.repository.update(supplier_id, {
|
||||
'status': SupplierStatus.INACTIVE,
|
||||
'updated_at': datetime.utcnow()
|
||||
})
|
||||
|
||||
logger.info("Supplier deleted successfully", supplier_id=str(supplier_id))
|
||||
return True
|
||||
|
||||
async def search_suppliers(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
search_params: SupplierSearchParams
|
||||
) -> List[Supplier]:
|
||||
"""Search suppliers with filters"""
|
||||
return self.repository.search_suppliers(
|
||||
tenant_id=tenant_id,
|
||||
search_term=search_params.search_term,
|
||||
supplier_type=search_params.supplier_type,
|
||||
status=search_params.status,
|
||||
limit=search_params.limit,
|
||||
offset=search_params.offset
|
||||
)
|
||||
|
||||
async def get_active_suppliers(self, tenant_id: UUID) -> List[Supplier]:
|
||||
"""Get all active suppliers"""
|
||||
return self.repository.get_active_suppliers(tenant_id)
|
||||
|
||||
async def get_suppliers_by_type(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_type: SupplierType
|
||||
) -> List[Supplier]:
|
||||
"""Get suppliers by type"""
|
||||
return self.repository.get_suppliers_by_type(tenant_id, supplier_type)
|
||||
|
||||
async def get_top_suppliers(self, tenant_id: UUID, limit: int = 10) -> List[Supplier]:
|
||||
"""Get top performing suppliers"""
|
||||
return self.repository.get_top_suppliers(tenant_id, limit)
|
||||
|
||||
async def approve_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
approved_by: UUID,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[Supplier]:
|
||||
"""Approve a pending supplier"""
|
||||
logger.info("Approving supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.approve_supplier(supplier_id, approved_by)
|
||||
if not supplier:
|
||||
logger.warning("Failed to approve supplier - not found or not pending")
|
||||
return None
|
||||
|
||||
if notes:
|
||||
self.repository.update(supplier_id, {
|
||||
'notes': (supplier.notes or "") + f"\nApproval notes: {notes}",
|
||||
'updated_at': datetime.utcnow()
|
||||
})
|
||||
|
||||
logger.info("Supplier approved successfully", supplier_id=str(supplier_id))
|
||||
return supplier
|
||||
|
||||
async def reject_supplier(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
rejection_reason: str,
|
||||
rejected_by: UUID
|
||||
) -> Optional[Supplier]:
|
||||
"""Reject a pending supplier"""
|
||||
logger.info("Rejecting supplier", supplier_id=str(supplier_id))
|
||||
|
||||
supplier = self.repository.reject_supplier(
|
||||
supplier_id, rejection_reason, rejected_by
|
||||
)
|
||||
if not supplier:
|
||||
logger.warning("Failed to reject supplier - not found or not pending")
|
||||
return None
|
||||
|
||||
logger.info("Supplier rejected successfully", supplier_id=str(supplier_id))
|
||||
return supplier
|
||||
|
||||
async def update_supplier_performance(
|
||||
self,
|
||||
supplier_id: UUID,
|
||||
quality_rating: Optional[float] = None,
|
||||
delivery_rating: Optional[float] = None,
|
||||
order_increment: int = 0,
|
||||
amount_increment: float = 0.0
|
||||
) -> Optional[Supplier]:
|
||||
"""Update supplier performance metrics"""
|
||||
logger.info("Updating supplier performance", supplier_id=str(supplier_id))
|
||||
|
||||
return self.repository.update_supplier_stats(
|
||||
supplier_id=supplier_id,
|
||||
total_orders_increment=order_increment,
|
||||
total_amount_increment=amount_increment,
|
||||
new_quality_rating=quality_rating,
|
||||
new_delivery_rating=delivery_rating
|
||||
)
|
||||
|
||||
async def get_supplier_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get supplier statistics for dashboard"""
|
||||
return self.repository.get_supplier_statistics(tenant_id)
|
||||
|
||||
async def get_suppliers_needing_review(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
days_since_last_order: int = 30
|
||||
) -> List[Supplier]:
|
||||
"""Get suppliers that may need performance review"""
|
||||
return self.repository.get_suppliers_needing_review(
|
||||
tenant_id, days_since_last_order
|
||||
)
|
||||
|
||||
def _generate_supplier_code(self, supplier_name: str) -> str:
|
||||
"""Generate supplier code from name"""
|
||||
# Take first 3 characters of each word, uppercase
|
||||
words = supplier_name.strip().split()[:3] # Max 3 words
|
||||
code_parts = []
|
||||
|
||||
for word in words:
|
||||
if len(word) >= 3:
|
||||
code_parts.append(word[:3].upper())
|
||||
else:
|
||||
code_parts.append(word.upper())
|
||||
|
||||
base_code = "".join(code_parts)[:8] # Max 8 characters
|
||||
|
||||
# Add random suffix to ensure uniqueness
|
||||
import random
|
||||
import string
|
||||
suffix = ''.join(random.choices(string.digits, k=2))
|
||||
|
||||
return f"{base_code}{suffix}"
|
||||
|
||||
async def validate_supplier_data(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
supplier_data: Dict[str, Any],
|
||||
supplier_id: Optional[UUID] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Validate supplier data and return errors"""
|
||||
errors = {}
|
||||
|
||||
# Check required fields
|
||||
if not supplier_data.get('name'):
|
||||
errors['name'] = "Supplier name is required"
|
||||
|
||||
# Check email format if provided
|
||||
email = supplier_data.get('email')
|
||||
if email:
|
||||
import re
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(email_pattern, email):
|
||||
errors['email'] = "Invalid email format"
|
||||
|
||||
# Check phone format if provided
|
||||
phone = supplier_data.get('phone')
|
||||
if phone:
|
||||
# Basic phone validation (digits, spaces, dashes, parentheses)
|
||||
import re
|
||||
phone_pattern = r'^[\d\s\-\(\)\+]+$'
|
||||
if not re.match(phone_pattern, phone):
|
||||
errors['phone'] = "Invalid phone format"
|
||||
|
||||
# Check lead time range
|
||||
lead_time = supplier_data.get('standard_lead_time')
|
||||
if lead_time is not None:
|
||||
if lead_time < 0 or lead_time > 365:
|
||||
errors['standard_lead_time'] = "Lead time must be between 0 and 365 days"
|
||||
|
||||
# Check credit limit
|
||||
credit_limit = supplier_data.get('credit_limit')
|
||||
if credit_limit is not None and credit_limit < 0:
|
||||
errors['credit_limit'] = "Credit limit cannot be negative"
|
||||
|
||||
# Check minimum order amount
|
||||
min_order = supplier_data.get('minimum_order_amount')
|
||||
if min_order is not None and min_order < 0:
|
||||
errors['minimum_order_amount'] = "Minimum order amount cannot be negative"
|
||||
|
||||
return errors
|
||||
Reference in New Issue
Block a user