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/repositories/__init__.py

View File

@@ -0,0 +1,96 @@
# services/suppliers/app/repositories/base.py
"""
Base repository class for common database operations
"""
from typing import TypeVar, Generic, List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc
from uuid import UUID
T = TypeVar('T')
class BaseRepository(Generic[T]):
"""Base repository with common CRUD operations"""
def __init__(self, model: type, db: Session):
self.model = model
self.db = db
def create(self, obj_data: Dict[str, Any]) -> T:
"""Create a new record"""
db_obj = self.model(**obj_data)
self.db.add(db_obj)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def get_by_id(self, record_id: UUID) -> Optional[T]:
"""Get record by ID"""
return self.db.query(self.model).filter(self.model.id == record_id).first()
def get_by_tenant_id(self, tenant_id: UUID, limit: int = 100, offset: int = 0) -> List[T]:
"""Get records by tenant ID with pagination"""
return (
self.db.query(self.model)
.filter(self.model.tenant_id == tenant_id)
.limit(limit)
.offset(offset)
.all()
)
def update(self, record_id: UUID, update_data: Dict[str, Any]) -> Optional[T]:
"""Update record by ID"""
db_obj = self.get_by_id(record_id)
if db_obj:
for key, value in update_data.items():
if hasattr(db_obj, key):
setattr(db_obj, key, value)
self.db.commit()
self.db.refresh(db_obj)
return db_obj
def delete(self, record_id: UUID) -> bool:
"""Delete record by ID"""
db_obj = self.get_by_id(record_id)
if db_obj:
self.db.delete(db_obj)
self.db.commit()
return True
return False
def count_by_tenant(self, tenant_id: UUID) -> int:
"""Count records by tenant"""
return self.db.query(self.model).filter(self.model.tenant_id == tenant_id).count()
def list_with_filters(
self,
tenant_id: UUID,
filters: Optional[Dict[str, Any]] = None,
sort_by: str = "created_at",
sort_order: str = "desc",
limit: int = 100,
offset: int = 0
) -> List[T]:
"""List records with filtering and sorting"""
query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id)
# Apply filters
if filters:
for key, value in filters.items():
if hasattr(self.model, key) and value is not None:
query = query.filter(getattr(self.model, key) == value)
# Apply sorting
if hasattr(self.model, sort_by):
if sort_order.lower() == "desc":
query = query.order_by(desc(getattr(self.model, sort_by)))
else:
query = query.order_by(asc(getattr(self.model, sort_by)))
return query.limit(limit).offset(offset).all()
def exists(self, record_id: UUID) -> bool:
"""Check if record exists"""
return self.db.query(self.model).filter(self.model.id == record_id).first() is not None

View File

@@ -0,0 +1,414 @@
# services/suppliers/app/repositories/delivery_repository.py
"""
Delivery repository for database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_, func, desc
from uuid import UUID
from datetime import datetime, timedelta
from app.models.suppliers import (
Delivery, DeliveryItem, DeliveryStatus,
PurchaseOrder, Supplier
)
from app.repositories.base import BaseRepository
class DeliveryRepository(BaseRepository[Delivery]):
"""Repository for delivery tracking operations"""
def __init__(self, db: Session):
super().__init__(Delivery, db)
def get_by_delivery_number(
self,
tenant_id: UUID,
delivery_number: str
) -> Optional[Delivery]:
"""Get delivery by delivery number within tenant"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.delivery_number == delivery_number
)
)
.first()
)
def get_with_items(self, delivery_id: UUID) -> Optional[Delivery]:
"""Get delivery with all items loaded"""
return (
self.db.query(self.model)
.options(
joinedload(self.model.items),
joinedload(self.model.purchase_order),
joinedload(self.model.supplier)
)
.filter(self.model.id == delivery_id)
.first()
)
def get_by_purchase_order(self, po_id: UUID) -> List[Delivery]:
"""Get all deliveries for a purchase order"""
return (
self.db.query(self.model)
.options(joinedload(self.model.items))
.filter(self.model.purchase_order_id == po_id)
.order_by(self.model.scheduled_date.desc())
.all()
)
def search_deliveries(
self,
tenant_id: UUID,
supplier_id: Optional[UUID] = None,
status: Optional[DeliveryStatus] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
search_term: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Delivery]:
"""Search deliveries with comprehensive filters"""
query = (
self.db.query(self.model)
.options(
joinedload(self.model.supplier),
joinedload(self.model.purchase_order)
)
.filter(self.model.tenant_id == tenant_id)
)
# Supplier filter
if supplier_id:
query = query.filter(self.model.supplier_id == supplier_id)
# Status filter
if status:
query = query.filter(self.model.status == status)
# Date range filter (scheduled date)
if date_from:
query = query.filter(self.model.scheduled_date >= date_from)
if date_to:
query = query.filter(self.model.scheduled_date <= date_to)
# Search term filter
if search_term:
search_filter = or_(
self.model.delivery_number.ilike(f"%{search_term}%"),
self.model.supplier_delivery_note.ilike(f"%{search_term}%"),
self.model.tracking_number.ilike(f"%{search_term}%"),
self.model.purchase_order.has(PurchaseOrder.po_number.ilike(f"%{search_term}%"))
)
query = query.filter(search_filter)
return (
query.order_by(desc(self.model.scheduled_date))
.limit(limit)
.offset(offset)
.all()
)
def get_scheduled_deliveries(
self,
tenant_id: UUID,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> List[Delivery]:
"""Get scheduled deliveries for a date range"""
if not date_from:
date_from = datetime.utcnow()
if not date_to:
date_to = date_from + timedelta(days=7) # Next week
return (
self.db.query(self.model)
.options(
joinedload(self.model.supplier),
joinedload(self.model.purchase_order)
)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
DeliveryStatus.SCHEDULED,
DeliveryStatus.IN_TRANSIT,
DeliveryStatus.OUT_FOR_DELIVERY
]),
self.model.scheduled_date >= date_from,
self.model.scheduled_date <= date_to
)
)
.order_by(self.model.scheduled_date)
.all()
)
def get_todays_deliveries(self, tenant_id: UUID) -> List[Delivery]:
"""Get deliveries scheduled for today"""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
return self.get_scheduled_deliveries(tenant_id, today_start, today_end)
def get_overdue_deliveries(self, tenant_id: UUID) -> List[Delivery]:
"""Get deliveries that are overdue (scheduled in the past but not completed)"""
now = datetime.utcnow()
return (
self.db.query(self.model)
.options(
joinedload(self.model.supplier),
joinedload(self.model.purchase_order)
)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
DeliveryStatus.SCHEDULED,
DeliveryStatus.IN_TRANSIT,
DeliveryStatus.OUT_FOR_DELIVERY
]),
self.model.scheduled_date < now
)
)
.order_by(self.model.scheduled_date)
.all()
)
def generate_delivery_number(self, tenant_id: UUID) -> str:
"""Generate next delivery number for tenant"""
# Get current date
today = datetime.utcnow()
date_prefix = f"DEL{today.strftime('%Y%m%d')}"
# Find highest delivery number for today
latest_delivery = (
self.db.query(self.model.delivery_number)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.delivery_number.like(f"{date_prefix}%")
)
)
.order_by(self.model.delivery_number.desc())
.first()
)
if latest_delivery:
try:
last_number = int(latest_delivery.delivery_number.replace(date_prefix, ""))
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
return f"{date_prefix}{new_number:03d}"
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 with appropriate timestamps"""
delivery = self.get_by_id(delivery_id)
if not delivery:
return None
old_status = delivery.status
delivery.status = status
delivery.created_by = updated_by # Track who updated
if update_timestamps:
now = datetime.utcnow()
if status == DeliveryStatus.IN_TRANSIT and not delivery.estimated_arrival:
# Set estimated arrival if not already set
delivery.estimated_arrival = now + timedelta(hours=4) # Default 4 hours
elif status == DeliveryStatus.DELIVERED:
delivery.actual_arrival = now
delivery.completed_at = now
elif status == DeliveryStatus.PARTIALLY_DELIVERED:
delivery.actual_arrival = now
elif status == DeliveryStatus.FAILED_DELIVERY:
delivery.actual_arrival = now
# Add status change note
if notes:
existing_notes = delivery.notes or ""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
status_note = f"[{timestamp}] Status: {old_status.value}{status.value}: {notes}"
delivery.notes = f"{existing_notes}\n{status_note}".strip()
delivery.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(delivery)
return delivery
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
) -> Optional[Delivery]:
"""Mark delivery as received with inspection details"""
delivery = self.get_by_id(delivery_id)
if not delivery:
return None
delivery.status = DeliveryStatus.DELIVERED
delivery.received_by = received_by
delivery.received_at = datetime.utcnow()
delivery.completed_at = datetime.utcnow()
delivery.inspection_passed = inspection_passed
if inspection_notes:
delivery.inspection_notes = inspection_notes
if quality_issues:
delivery.quality_issues = quality_issues
if not delivery.actual_arrival:
delivery.actual_arrival = datetime.utcnow()
delivery.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(delivery)
return delivery
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"""
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
query = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.created_at >= cutoff_date
)
)
)
if supplier_id:
query = query.filter(self.model.supplier_id == supplier_id)
deliveries = query.all()
if not deliveries:
return {
"total_deliveries": 0,
"on_time_deliveries": 0,
"late_deliveries": 0,
"failed_deliveries": 0,
"on_time_percentage": 0.0,
"avg_delay_hours": 0.0,
"quality_pass_rate": 0.0
}
total_count = len(deliveries)
on_time_count = 0
late_count = 0
failed_count = 0
total_delay_hours = 0
quality_pass_count = 0
quality_total = 0
for delivery in deliveries:
if delivery.status == DeliveryStatus.FAILED_DELIVERY:
failed_count += 1
continue
# Check if delivery was on time
if delivery.scheduled_date and delivery.actual_arrival:
if delivery.actual_arrival <= delivery.scheduled_date:
on_time_count += 1
else:
late_count += 1
delay = delivery.actual_arrival - delivery.scheduled_date
total_delay_hours += delay.total_seconds() / 3600
# Check quality inspection
if delivery.inspection_passed is not None:
quality_total += 1
if delivery.inspection_passed:
quality_pass_count += 1
on_time_percentage = (on_time_count / total_count * 100) if total_count > 0 else 0
avg_delay_hours = total_delay_hours / late_count if late_count > 0 else 0
quality_pass_rate = (quality_pass_count / quality_total * 100) if quality_total > 0 else 0
return {
"total_deliveries": total_count,
"on_time_deliveries": on_time_count,
"late_deliveries": late_count,
"failed_deliveries": failed_count,
"on_time_percentage": round(on_time_percentage, 1),
"avg_delay_hours": round(avg_delay_hours, 1),
"quality_pass_rate": round(quality_pass_rate, 1)
}
def get_upcoming_deliveries_summary(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get summary of upcoming deliveries"""
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
next_week = today + timedelta(days=7)
# Today's deliveries
todays_deliveries = len(self.get_todays_deliveries(tenant_id))
# This week's deliveries
this_week_deliveries = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.scheduled_date >= today,
self.model.scheduled_date <= next_week,
self.model.status.in_([
DeliveryStatus.SCHEDULED,
DeliveryStatus.IN_TRANSIT,
DeliveryStatus.OUT_FOR_DELIVERY
])
)
)
.count()
)
# Overdue deliveries
overdue_count = len(self.get_overdue_deliveries(tenant_id))
# In transit deliveries
in_transit_count = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == DeliveryStatus.IN_TRANSIT
)
)
.count()
)
return {
"todays_deliveries": todays_deliveries,
"this_week_deliveries": this_week_deliveries,
"overdue_deliveries": overdue_count,
"in_transit_deliveries": in_transit_count
}

View File

@@ -0,0 +1,300 @@
# services/suppliers/app/repositories/purchase_order_item_repository.py
"""
Purchase Order Item repository for database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from uuid import UUID
from datetime import datetime
from app.models.suppliers import PurchaseOrderItem
from app.repositories.base import BaseRepository
class PurchaseOrderItemRepository(BaseRepository[PurchaseOrderItem]):
"""Repository for purchase order item operations"""
def __init__(self, db: Session):
super().__init__(PurchaseOrderItem, db)
def get_by_purchase_order(self, po_id: UUID) -> List[PurchaseOrderItem]:
"""Get all items for a purchase order"""
return (
self.db.query(self.model)
.filter(self.model.purchase_order_id == po_id)
.order_by(self.model.created_at)
.all()
)
def get_by_ingredient(
self,
tenant_id: UUID,
ingredient_id: UUID,
limit: int = 20
) -> List[PurchaseOrderItem]:
"""Get recent order items for a specific ingredient"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.ingredient_id == ingredient_id
)
)
.order_by(self.model.created_at.desc())
.limit(limit)
.all()
)
def update_received_quantity(
self,
item_id: UUID,
received_quantity: int,
update_remaining: bool = True
) -> Optional[PurchaseOrderItem]:
"""Update received quantity for an item"""
item = self.get_by_id(item_id)
if not item:
return None
item.received_quantity = max(0, received_quantity)
if update_remaining:
item.remaining_quantity = max(0, item.ordered_quantity - item.received_quantity)
item.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(item)
return item
def add_received_quantity(
self,
item_id: UUID,
quantity_to_add: int
) -> Optional[PurchaseOrderItem]:
"""Add to received quantity (for partial deliveries)"""
item = self.get_by_id(item_id)
if not item:
return None
new_received = item.received_quantity + quantity_to_add
new_received = min(new_received, item.ordered_quantity) # Cap at ordered quantity
return self.update_received_quantity(item_id, new_received)
def get_partially_received_items(self, tenant_id: UUID) -> List[PurchaseOrderItem]:
"""Get items that are partially received (have remaining quantity)"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.received_quantity > 0,
self.model.remaining_quantity > 0
)
)
.order_by(self.model.updated_at.desc())
.all()
)
def get_pending_receipt_items(
self,
tenant_id: UUID,
ingredient_id: Optional[UUID] = None
) -> List[PurchaseOrderItem]:
"""Get items pending receipt (not yet delivered)"""
query = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.remaining_quantity > 0
)
)
)
if ingredient_id:
query = query.filter(self.model.ingredient_id == ingredient_id)
return query.order_by(self.model.created_at).all()
def calculate_line_total(self, item_id: UUID) -> Optional[PurchaseOrderItem]:
"""Recalculate line total for an item"""
item = self.get_by_id(item_id)
if not item:
return None
item.line_total = item.ordered_quantity * item.unit_price
item.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(item)
return item
def get_ingredient_purchase_history(
self,
tenant_id: UUID,
ingredient_id: UUID,
days_back: int = 90
) -> Dict[str, Any]:
"""Get purchase history and analytics for an ingredient"""
from datetime import timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Get items within date range
items = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.ingredient_id == ingredient_id,
self.model.created_at >= cutoff_date
)
)
.all()
)
if not items:
return {
"total_quantity_ordered": 0,
"total_amount_spent": 0.0,
"average_unit_price": 0.0,
"order_count": 0,
"last_order_date": None,
"price_trend": "stable"
}
# Calculate statistics
total_quantity = sum(item.ordered_quantity for item in items)
total_amount = sum(float(item.line_total) for item in items)
order_count = len(items)
avg_unit_price = total_amount / total_quantity if total_quantity > 0 else 0
last_order_date = max(item.created_at for item in items)
# Price trend analysis (simple)
if order_count >= 2:
sorted_items = sorted(items, key=lambda x: x.created_at)
first_half = sorted_items[:order_count//2]
second_half = sorted_items[order_count//2:]
avg_price_first = sum(float(item.unit_price) for item in first_half) / len(first_half)
avg_price_second = sum(float(item.unit_price) for item in second_half) / len(second_half)
if avg_price_second > avg_price_first * 1.1:
price_trend = "increasing"
elif avg_price_second < avg_price_first * 0.9:
price_trend = "decreasing"
else:
price_trend = "stable"
else:
price_trend = "insufficient_data"
return {
"total_quantity_ordered": total_quantity,
"total_amount_spent": round(total_amount, 2),
"average_unit_price": round(avg_unit_price, 4),
"order_count": order_count,
"last_order_date": last_order_date,
"price_trend": price_trend
}
def get_top_purchased_ingredients(
self,
tenant_id: UUID,
days_back: int = 30,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get most purchased ingredients by quantity or value"""
from datetime import timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Group by ingredient and calculate totals
results = (
self.db.query(
self.model.ingredient_id,
self.model.product_name,
self.model.unit_of_measure,
func.sum(self.model.ordered_quantity).label('total_quantity'),
func.sum(self.model.line_total).label('total_amount'),
func.count(self.model.id).label('order_count'),
func.avg(self.model.unit_price).label('avg_unit_price')
)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.created_at >= cutoff_date
)
)
.group_by(
self.model.ingredient_id,
self.model.product_name,
self.model.unit_of_measure
)
.order_by(func.sum(self.model.line_total).desc())
.limit(limit)
.all()
)
return [
{
"ingredient_id": str(row.ingredient_id),
"product_name": row.product_name,
"unit_of_measure": row.unit_of_measure,
"total_quantity": int(row.total_quantity),
"total_amount": round(float(row.total_amount), 2),
"order_count": int(row.order_count),
"avg_unit_price": round(float(row.avg_unit_price), 4)
}
for row in results
]
def bulk_update_items(
self,
po_id: UUID,
item_updates: List[Dict[str, Any]]
) -> List[PurchaseOrderItem]:
"""Bulk update multiple items in a purchase order"""
updated_items = []
for update_data in item_updates:
item_id = update_data.get('id')
if not item_id:
continue
item = (
self.db.query(self.model)
.filter(
and_(
self.model.id == item_id,
self.model.purchase_order_id == po_id
)
)
.first()
)
if item:
# Update allowed fields
for key, value in update_data.items():
if key != 'id' and hasattr(item, key):
setattr(item, key, value)
# Recalculate line total if quantity or price changed
if 'ordered_quantity' in update_data or 'unit_price' in update_data:
item.line_total = item.ordered_quantity * item.unit_price
item.remaining_quantity = item.ordered_quantity - item.received_quantity
item.updated_at = datetime.utcnow()
updated_items.append(item)
self.db.commit()
# Refresh all items
for item in updated_items:
self.db.refresh(item)
return updated_items

View File

@@ -0,0 +1,376 @@
# services/suppliers/app/repositories/purchase_order_repository.py
"""
Purchase Order repository for database operations
"""
from typing import List, Optional, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_, func, desc
from uuid import UUID
from datetime import datetime, timedelta
from app.models.suppliers import (
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus,
Supplier, SupplierPriceList
)
from app.repositories.base import BaseRepository
class PurchaseOrderRepository(BaseRepository[PurchaseOrder]):
"""Repository for purchase order management operations"""
def __init__(self, db: Session):
super().__init__(PurchaseOrder, db)
def get_by_po_number(self, tenant_id: UUID, po_number: str) -> Optional[PurchaseOrder]:
"""Get purchase order by PO number within tenant"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.po_number == po_number
)
)
.first()
)
def get_with_items(self, po_id: UUID) -> Optional[PurchaseOrder]:
"""Get purchase order with all items loaded"""
return (
self.db.query(self.model)
.options(joinedload(self.model.items))
.filter(self.model.id == po_id)
.first()
)
def search_purchase_orders(
self,
tenant_id: UUID,
supplier_id: Optional[UUID] = None,
status: Optional[PurchaseOrderStatus] = None,
priority: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
search_term: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[PurchaseOrder]:
"""Search purchase orders with comprehensive filters"""
query = (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(self.model.tenant_id == tenant_id)
)
# Supplier filter
if supplier_id:
query = query.filter(self.model.supplier_id == supplier_id)
# Status filter
if status:
query = query.filter(self.model.status == status)
# Priority filter
if priority:
query = query.filter(self.model.priority == priority)
# Date range filter
if date_from:
query = query.filter(self.model.order_date >= date_from)
if date_to:
query = query.filter(self.model.order_date <= date_to)
# Search term filter (PO number, reference, supplier name)
if search_term:
search_filter = or_(
self.model.po_number.ilike(f"%{search_term}%"),
self.model.reference_number.ilike(f"%{search_term}%"),
self.model.supplier.has(Supplier.name.ilike(f"%{search_term}%"))
)
query = query.filter(search_filter)
return (
query.order_by(desc(self.model.order_date))
.limit(limit)
.offset(offset)
.all()
)
def get_orders_by_status(
self,
tenant_id: UUID,
status: PurchaseOrderStatus
) -> List[PurchaseOrder]:
"""Get all orders with specific status"""
return (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == status
)
)
.order_by(self.model.order_date.desc())
.all()
)
def get_orders_requiring_approval(self, tenant_id: UUID) -> List[PurchaseOrder]:
"""Get orders pending approval"""
return (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == PurchaseOrderStatus.PENDING_APPROVAL,
self.model.requires_approval == True
)
)
.order_by(self.model.order_date.asc())
.all()
)
def get_overdue_orders(self, tenant_id: UUID) -> List[PurchaseOrder]:
"""Get orders that are overdue for delivery"""
today = datetime.utcnow().date()
return (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.CONFIRMED,
PurchaseOrderStatus.SENT_TO_SUPPLIER,
PurchaseOrderStatus.PARTIALLY_RECEIVED
]),
self.model.required_delivery_date < today
)
)
.order_by(self.model.required_delivery_date.asc())
.all()
)
def get_orders_by_supplier(
self,
tenant_id: UUID,
supplier_id: UUID,
limit: int = 20
) -> List[PurchaseOrder]:
"""Get recent orders for a specific supplier"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_id == supplier_id
)
)
.order_by(self.model.order_date.desc())
.limit(limit)
.all()
)
def generate_po_number(self, tenant_id: UUID) -> str:
"""Generate next PO number for tenant"""
# Get current year
current_year = datetime.utcnow().year
# Find highest PO number for current year
year_prefix = f"PO{current_year}"
latest_po = (
self.db.query(self.model.po_number)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.po_number.like(f"{year_prefix}%")
)
)
.order_by(self.model.po_number.desc())
.first()
)
if latest_po:
# Extract number and increment
try:
last_number = int(latest_po.po_number.replace(year_prefix, ""))
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
return f"{year_prefix}{new_number:04d}"
def update_order_status(
self,
po_id: UUID,
status: PurchaseOrderStatus,
updated_by: UUID,
notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Update purchase order status with audit trail"""
po = self.get_by_id(po_id)
if not po:
return None
po.status = status
po.updated_by = updated_by
po.updated_at = datetime.utcnow()
if notes:
existing_notes = po.internal_notes or ""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
po.internal_notes = f"{existing_notes}\n[{timestamp}] Status changed to {status.value}: {notes}".strip()
# Set specific timestamps based on status
if status == PurchaseOrderStatus.APPROVED:
po.approved_at = datetime.utcnow()
elif status == PurchaseOrderStatus.SENT_TO_SUPPLIER:
po.sent_to_supplier_at = datetime.utcnow()
elif status == PurchaseOrderStatus.CONFIRMED:
po.supplier_confirmation_date = datetime.utcnow()
self.db.commit()
self.db.refresh(po)
return po
def approve_order(
self,
po_id: UUID,
approved_by: UUID,
approval_notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Approve a purchase order"""
po = self.get_by_id(po_id)
if not po or po.status != PurchaseOrderStatus.PENDING_APPROVAL:
return None
po.status = PurchaseOrderStatus.APPROVED
po.approved_by = approved_by
po.approved_at = datetime.utcnow()
po.updated_by = approved_by
po.updated_at = datetime.utcnow()
if approval_notes:
po.internal_notes = (po.internal_notes or "") + f"\nApproval notes: {approval_notes}"
self.db.commit()
self.db.refresh(po)
return po
def calculate_order_totals(self, po_id: UUID) -> Optional[PurchaseOrder]:
"""Recalculate order totals based on line items"""
po = self.get_with_items(po_id)
if not po:
return None
# Calculate subtotal from items
subtotal = sum(item.line_total for item in po.items)
# Keep existing tax, shipping, and discount
tax_amount = po.tax_amount or 0
shipping_cost = po.shipping_cost or 0
discount_amount = po.discount_amount or 0
# Calculate total
total_amount = subtotal + tax_amount + shipping_cost - discount_amount
# Update PO
po.subtotal = subtotal
po.total_amount = max(0, total_amount) # Ensure non-negative
po.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(po)
return po
def get_purchase_order_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get purchase order statistics for dashboard"""
# Total orders
total_orders = self.count_by_tenant(tenant_id)
# Orders by status
status_counts = (
self.db.query(
self.model.status,
func.count(self.model.id).label('count')
)
.filter(self.model.tenant_id == tenant_id)
.group_by(self.model.status)
.all()
)
status_dict = {status.value: 0 for status in PurchaseOrderStatus}
for status, count in status_counts:
status_dict[status.value] = count
# This month's orders
first_day_month = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
this_month_orders = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.order_date >= first_day_month
)
)
.count()
)
# Total spend this month
this_month_spend = (
self.db.query(func.sum(self.model.total_amount))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.order_date >= first_day_month,
self.model.status != PurchaseOrderStatus.CANCELLED
)
)
.scalar()
) or 0.0
# Average order value
avg_order_value = (
self.db.query(func.avg(self.model.total_amount))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status != PurchaseOrderStatus.CANCELLED
)
)
.scalar()
) or 0.0
# Overdue orders count
today = datetime.utcnow().date()
overdue_count = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.CONFIRMED,
PurchaseOrderStatus.SENT_TO_SUPPLIER,
PurchaseOrderStatus.PARTIALLY_RECEIVED
]),
self.model.required_delivery_date < today
)
)
.count()
)
return {
"total_orders": total_orders,
"status_counts": status_dict,
"this_month_orders": this_month_orders,
"this_month_spend": float(this_month_spend),
"avg_order_value": round(float(avg_order_value), 2),
"overdue_count": overdue_count,
"pending_approval": status_dict.get(PurchaseOrderStatus.PENDING_APPROVAL.value, 0)
}

View File

@@ -0,0 +1,298 @@
# services/suppliers/app/repositories/supplier_repository.py
"""
Supplier repository for database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from uuid import UUID
from datetime import datetime
from app.models.suppliers import Supplier, SupplierStatus, SupplierType
from app.repositories.base import BaseRepository
class SupplierRepository(BaseRepository[Supplier]):
"""Repository for supplier management operations"""
def __init__(self, db: Session):
super().__init__(Supplier, db)
def get_by_name(self, tenant_id: UUID, name: str) -> Optional[Supplier]:
"""Get supplier by name within tenant"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.name == name
)
)
.first()
)
def get_by_supplier_code(self, tenant_id: UUID, supplier_code: str) -> Optional[Supplier]:
"""Get supplier by supplier code within tenant"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_code == supplier_code
)
)
.first()
)
def search_suppliers(
self,
tenant_id: UUID,
search_term: Optional[str] = None,
supplier_type: Optional[SupplierType] = None,
status: Optional[SupplierStatus] = None,
limit: int = 50,
offset: int = 0
) -> List[Supplier]:
"""Search suppliers with filters"""
query = self.db.query(self.model).filter(self.model.tenant_id == tenant_id)
# Search term filter (name, contact person, email)
if search_term:
search_filter = or_(
self.model.name.ilike(f"%{search_term}%"),
self.model.contact_person.ilike(f"%{search_term}%"),
self.model.email.ilike(f"%{search_term}%")
)
query = query.filter(search_filter)
# Type filter
if supplier_type:
query = query.filter(self.model.supplier_type == supplier_type)
# Status filter
if status:
query = query.filter(self.model.status == status)
return query.order_by(self.model.name).limit(limit).offset(offset).all()
def get_active_suppliers(self, tenant_id: UUID) -> List[Supplier]:
"""Get all active suppliers for a tenant"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE
)
)
.order_by(self.model.name)
.all()
)
def get_suppliers_by_type(
self,
tenant_id: UUID,
supplier_type: SupplierType
) -> List[Supplier]:
"""Get suppliers by type"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_type == supplier_type,
self.model.status == SupplierStatus.ACTIVE
)
)
.order_by(self.model.quality_rating.desc(), self.model.name)
.all()
)
def get_top_suppliers(
self,
tenant_id: UUID,
limit: int = 10
) -> List[Supplier]:
"""Get top suppliers by quality rating and order value"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE
)
)
.order_by(
self.model.quality_rating.desc(),
self.model.total_amount.desc()
)
.limit(limit)
.all()
)
def update_supplier_stats(
self,
supplier_id: UUID,
total_orders_increment: int = 0,
total_amount_increment: float = 0.0,
new_quality_rating: Optional[float] = None,
new_delivery_rating: Optional[float] = None
) -> Optional[Supplier]:
"""Update supplier performance statistics"""
supplier = self.get_by_id(supplier_id)
if not supplier:
return None
# Update counters
if total_orders_increment:
supplier.total_orders += total_orders_increment
if total_amount_increment:
supplier.total_amount += total_amount_increment
# Update ratings (these should be calculated averages)
if new_quality_rating is not None:
supplier.quality_rating = new_quality_rating
if new_delivery_rating is not None:
supplier.delivery_rating = new_delivery_rating
supplier.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(supplier)
return supplier
def get_suppliers_needing_review(
self,
tenant_id: UUID,
days_since_last_order: int = 30
) -> List[Supplier]:
"""Get suppliers that may need performance review"""
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_since_last_order)
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE,
or_(
self.model.quality_rating < 3.0, # Poor rating
self.model.delivery_rating < 3.0, # Poor delivery
self.model.updated_at < cutoff_date # Long time since interaction
)
)
)
.order_by(self.model.quality_rating.asc())
.all()
)
def get_supplier_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get supplier statistics for dashboard"""
total_suppliers = self.count_by_tenant(tenant_id)
active_suppliers = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE
)
)
.count()
)
pending_suppliers = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.PENDING_APPROVAL
)
)
.count()
)
avg_quality_rating = (
self.db.query(func.avg(self.model.quality_rating))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE,
self.model.quality_rating > 0
)
)
.scalar()
) or 0.0
avg_delivery_rating = (
self.db.query(func.avg(self.model.delivery_rating))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == SupplierStatus.ACTIVE,
self.model.delivery_rating > 0
)
)
.scalar()
) or 0.0
total_spend = (
self.db.query(func.sum(self.model.total_amount))
.filter(self.model.tenant_id == tenant_id)
.scalar()
) or 0.0
return {
"total_suppliers": total_suppliers,
"active_suppliers": active_suppliers,
"pending_suppliers": pending_suppliers,
"avg_quality_rating": round(float(avg_quality_rating), 2),
"avg_delivery_rating": round(float(avg_delivery_rating), 2),
"total_spend": float(total_spend)
}
def approve_supplier(
self,
supplier_id: UUID,
approved_by: UUID,
approval_date: Optional[datetime] = None
) -> Optional[Supplier]:
"""Approve a pending supplier"""
supplier = self.get_by_id(supplier_id)
if not supplier or supplier.status != SupplierStatus.PENDING_APPROVAL:
return None
supplier.status = SupplierStatus.ACTIVE
supplier.approved_by = approved_by
supplier.approved_at = approval_date or datetime.utcnow()
supplier.rejection_reason = None
supplier.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(supplier)
return supplier
def reject_supplier(
self,
supplier_id: UUID,
rejection_reason: str,
approved_by: UUID
) -> Optional[Supplier]:
"""Reject a pending supplier"""
supplier = self.get_by_id(supplier_id)
if not supplier or supplier.status != SupplierStatus.PENDING_APPROVAL:
return None
supplier.status = SupplierStatus.INACTIVE
supplier.rejection_reason = rejection_reason
supplier.approved_by = approved_by
supplier.approved_at = datetime.utcnow()
supplier.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(supplier)
return supplier