New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -1,414 +0,0 @@
# 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

@@ -1,297 +0,0 @@
# 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_inventory_product(
self,
tenant_id: UUID,
inventory_product_id: UUID,
limit: int = 20
) -> List[PurchaseOrderItem]:
"""Get recent order items for a specific inventory product"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.inventory_product_id == inventory_product_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,
inventory_product_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 inventory_product_id:
query = query.filter(self.model.inventory_product_id == inventory_product_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_inventory_product_purchase_history(
self,
tenant_id: UUID,
inventory_product_id: UUID,
days_back: int = 90
) -> Dict[str, Any]:
"""Get purchase history and analytics for an inventory product"""
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.inventory_product_id == inventory_product_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_inventory_products(
self,
tenant_id: UUID,
days_back: int = 30,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get most purchased inventory products by quantity or value"""
from datetime import timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Group by inventory product and calculate totals
results = (
self.db.query(
self.model.inventory_product_id,
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.inventory_product_id,
self.model.unit_of_measure
)
.order_by(func.sum(self.model.line_total).desc())
.limit(limit)
.all()
)
return [
{
"inventory_product_id": str(row.inventory_product_id),
"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

@@ -1,255 +0,0 @@
# services/suppliers/app/repositories/purchase_order_repository.py
"""
Purchase Order repository for database operations (Async SQLAlchemy 2.0)
"""
from typing import List, Optional, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy import select, 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: AsyncSession):
super().__init__(PurchaseOrder, db)
async def get_by_po_number(self, tenant_id: UUID, po_number: str) -> Optional[PurchaseOrder]:
"""Get purchase order by PO number within tenant"""
stmt = select(self.model).filter(
and_(
self.model.tenant_id == tenant_id,
self.model.po_number == po_number
)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_with_items(self, po_id: UUID) -> Optional[PurchaseOrder]:
"""Get purchase order with all items and supplier loaded"""
stmt = (
select(self.model)
.options(
selectinload(self.model.items),
selectinload(self.model.supplier)
)
.filter(self.model.id == po_id)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async 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"""
stmt = (
select(self.model)
.options(selectinload(self.model.supplier))
.filter(self.model.tenant_id == tenant_id)
)
# Supplier filter
if supplier_id:
stmt = stmt.filter(self.model.supplier_id == supplier_id)
# Status filter
if status:
stmt = stmt.filter(self.model.status == status)
# Priority filter
if priority:
stmt = stmt.filter(self.model.priority == priority)
# Date range filter
if date_from:
stmt = stmt.filter(self.model.order_date >= date_from)
if date_to:
stmt = stmt.filter(self.model.order_date <= date_to)
# Search term filter (PO number, reference)
if search_term:
search_filter = or_(
self.model.po_number.ilike(f"%{search_term}%"),
self.model.reference_number.ilike(f"%{search_term}%")
)
stmt = stmt.filter(search_filter)
stmt = stmt.order_by(desc(self.model.order_date)).limit(limit).offset(offset)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_orders_by_status(
self,
tenant_id: UUID,
status: PurchaseOrderStatus,
limit: int = 100
) -> List[PurchaseOrder]:
"""Get purchase orders by status"""
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == status
)
)
.order_by(desc(self.model.order_date))
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_pending_approval(
self,
tenant_id: UUID,
limit: int = 50
) -> List[PurchaseOrder]:
"""Get purchase orders pending approval"""
return await self.get_orders_by_status(
tenant_id,
PurchaseOrderStatus.pending_approval,
limit
)
async def get_by_supplier(
self,
tenant_id: UUID,
supplier_id: UUID,
limit: int = 100,
offset: int = 0
) -> List[PurchaseOrder]:
"""Get purchase orders for a specific supplier"""
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_id == supplier_id
)
)
.order_by(desc(self.model.order_date))
.limit(limit)
.offset(offset)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_orders_requiring_delivery(
self,
tenant_id: UUID,
days_ahead: int = 7
) -> List[PurchaseOrder]:
"""Get orders expecting delivery within specified days"""
cutoff_date = datetime.utcnow().date() + timedelta(days=days_ahead)
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
]),
self.model.required_delivery_date <= cutoff_date
)
)
.order_by(self.model.required_delivery_date)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_overdue_deliveries(self, tenant_id: UUID) -> List[PurchaseOrder]:
"""Get purchase orders with overdue deliveries"""
today = datetime.utcnow().date()
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
]),
self.model.required_delivery_date < today
)
)
.order_by(self.model.required_delivery_date)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_total_spent_by_supplier(
self,
tenant_id: UUID,
supplier_id: UUID,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> float:
"""Calculate total amount spent with a supplier"""
stmt = (
select(func.sum(self.model.total_amount))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_id == supplier_id,
self.model.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed,
PurchaseOrderStatus.received,
PurchaseOrderStatus.completed
])
)
)
)
if date_from:
stmt = stmt.filter(self.model.order_date >= date_from)
if date_to:
stmt = stmt.filter(self.model.order_date <= date_to)
result = await self.db.execute(stmt)
total = result.scalar()
return float(total) if total else 0.0
async def count_by_status(
self,
tenant_id: UUID,
status: PurchaseOrderStatus
) -> int:
"""Count purchase orders by status"""
stmt = (
select(func.count())
.select_from(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == status
)
)
)
result = await self.db.execute(stmt)
return result.scalar() or 0

View File

@@ -1,376 +0,0 @@
# 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

@@ -84,6 +84,20 @@ class SupplierRepository(BaseRepository[Supplier]):
).order_by(self.model.name)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_suppliers_by_ids(self, tenant_id: UUID, supplier_ids: List[UUID]) -> List[Supplier]:
"""Get multiple suppliers by IDs in a single query (batch fetch)"""
if not supplier_ids:
return []
stmt = select(self.model).filter(
and_(
self.model.tenant_id == tenant_id,
self.model.id.in_(supplier_ids)
)
).order_by(self.model.name)
result = await self.db.execute(stmt)
return result.scalars().all()
def get_suppliers_by_type(
self,