Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

@@ -0,0 +1 @@
# services/suppliers/app/services/__init__.py

View 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
}

View 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
)

View 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