Improve the frontend
This commit is contained in:
@@ -177,11 +177,33 @@ async def clone_demo_data(
|
||||
new_price_id = uuid.uuid4()
|
||||
price_list_map[price_list.id] = new_price_id
|
||||
|
||||
# Transform inventory_product_id to match virtual tenant's ingredient IDs
|
||||
# Using same formula as inventory service: tenant_int ^ base_int
|
||||
base_product_int = int(price_list.inventory_product_id.hex, 16)
|
||||
virtual_tenant_int = int(virtual_uuid.hex, 16)
|
||||
base_tenant_int = int(base_uuid.hex, 16)
|
||||
|
||||
# Reverse the original XOR to get the base ingredient ID
|
||||
# base_product = base_tenant ^ base_ingredient_id
|
||||
# So: base_ingredient_id = base_tenant ^ base_product
|
||||
base_ingredient_int = base_tenant_int ^ base_product_int
|
||||
|
||||
# Now apply virtual tenant XOR
|
||||
new_product_id = uuid.UUID(int=virtual_tenant_int ^ base_ingredient_int)
|
||||
|
||||
logger.debug(
|
||||
"Transforming price list product ID using XOR",
|
||||
supplier_name=supplier.name,
|
||||
base_product_id=str(price_list.inventory_product_id),
|
||||
new_product_id=str(new_product_id),
|
||||
product_code=price_list.product_code
|
||||
)
|
||||
|
||||
new_price_list = SupplierPriceList(
|
||||
id=new_price_id,
|
||||
tenant_id=virtual_uuid,
|
||||
supplier_id=new_supplier_id,
|
||||
inventory_product_id=price_list.inventory_product_id, # Keep product reference
|
||||
inventory_product_id=new_product_id, # Transformed for virtual tenant
|
||||
product_code=price_list.product_code,
|
||||
unit_price=price_list.unit_price,
|
||||
unit_of_measure=price_list.unit_of_measure,
|
||||
@@ -290,12 +312,20 @@ async def clone_demo_data(
|
||||
|
||||
new_price_list_id = price_list_map.get(item.price_list_item_id, item.price_list_item_id) if item.price_list_item_id else None
|
||||
|
||||
# Transform inventory_product_id to match virtual tenant's ingredient IDs
|
||||
if item.inventory_product_id:
|
||||
base_product_int = int(item.inventory_product_id.hex, 16)
|
||||
base_ingredient_int = base_tenant_int ^ base_product_int
|
||||
new_inventory_product_id = uuid.UUID(int=virtual_tenant_int ^ base_ingredient_int)
|
||||
else:
|
||||
new_inventory_product_id = None
|
||||
|
||||
new_item = PurchaseOrderItem(
|
||||
id=new_item_id,
|
||||
tenant_id=virtual_uuid,
|
||||
purchase_order_id=new_po_id,
|
||||
price_list_item_id=new_price_list_id,
|
||||
inventory_product_id=item.inventory_product_id, # Keep product reference
|
||||
inventory_product_id=new_inventory_product_id, # Transformed product reference
|
||||
product_code=item.product_code,
|
||||
ordered_quantity=item.ordered_quantity,
|
||||
unit_of_measure=item.unit_of_measure,
|
||||
@@ -377,12 +407,20 @@ async def clone_demo_data(
|
||||
for item in delivery_items:
|
||||
new_po_item_id = po_item_map.get(item.purchase_order_item_id, item.purchase_order_item_id)
|
||||
|
||||
# Transform inventory_product_id to match virtual tenant's ingredient IDs
|
||||
if item.inventory_product_id:
|
||||
base_product_int = int(item.inventory_product_id.hex, 16)
|
||||
base_ingredient_int = base_tenant_int ^ base_product_int
|
||||
new_inventory_product_id = uuid.UUID(int=virtual_tenant_int ^ base_ingredient_int)
|
||||
else:
|
||||
new_inventory_product_id = None
|
||||
|
||||
new_item = DeliveryItem(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=virtual_uuid,
|
||||
delivery_id=new_delivery_id,
|
||||
purchase_order_item_id=new_po_item_id,
|
||||
inventory_product_id=item.inventory_product_id, # Keep product reference
|
||||
inventory_product_id=new_inventory_product_id, # Transformed product reference
|
||||
ordered_quantity=item.ordered_quantity,
|
||||
delivered_quantity=item.delivered_quantity,
|
||||
accepted_quantity=item.accepted_quantity,
|
||||
|
||||
@@ -43,9 +43,9 @@ async def create_purchase_order(
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
purchase_order = await service.create_purchase_order(
|
||||
tenant_id=current_user.tenant_id,
|
||||
tenant_id=current_user["tenant_id"],
|
||||
po_data=po_data,
|
||||
created_by=current_user.user_id
|
||||
created_by=current_user["user_id"]
|
||||
)
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except ValueError as e:
|
||||
@@ -93,7 +93,9 @@ async def list_purchase_orders(
|
||||
status_enum = None
|
||||
if status:
|
||||
try:
|
||||
status_enum = PurchaseOrderStatus(status.upper())
|
||||
# Convert from PENDING_APPROVAL to pending_approval format
|
||||
status_value = status.lower()
|
||||
status_enum = PurchaseOrderStatus(status_value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
@@ -110,11 +112,29 @@ async def list_purchase_orders(
|
||||
)
|
||||
|
||||
orders = await service.search_purchase_orders(
|
||||
tenant_id=current_user.tenant_id,
|
||||
tenant_id=current_user["tenant_id"],
|
||||
search_params=search_params
|
||||
)
|
||||
|
||||
return [PurchaseOrderSummary.from_orm(order) for order in orders]
|
||||
|
||||
# Convert to response with supplier names
|
||||
response = []
|
||||
for order in orders:
|
||||
order_dict = {
|
||||
"id": order.id,
|
||||
"po_number": order.po_number,
|
||||
"supplier_id": order.supplier_id,
|
||||
"supplier_name": order.supplier.name if order.supplier else None,
|
||||
"status": order.status,
|
||||
"priority": order.priority,
|
||||
"order_date": order.order_date,
|
||||
"required_delivery_date": order.required_delivery_date,
|
||||
"total_amount": order.total_amount,
|
||||
"currency": order.currency,
|
||||
"created_at": order.created_at
|
||||
}
|
||||
response.append(PurchaseOrderSummary(**order_dict))
|
||||
|
||||
return response
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -134,14 +154,11 @@ async def get_purchase_order(
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
purchase_order = await service.get_purchase_order(po_id)
|
||||
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
# Check tenant access
|
||||
if purchase_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
# Tenant access control is handled by the gateway
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -168,13 +185,13 @@ async def update_purchase_order(
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
if existing_order.tenant_id != current_user["tenant_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
purchase_order = await service.update_purchase_order(
|
||||
po_id=po_id,
|
||||
po_data=po_data,
|
||||
updated_by=current_user.user_id
|
||||
updated_by=current_user["user_id"]
|
||||
)
|
||||
|
||||
if not purchase_order:
|
||||
@@ -206,7 +223,7 @@ async def delete_purchase_order(
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
if existing_order.tenant_id != current_user["tenant_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Capture PO data before deletion
|
||||
|
||||
@@ -9,8 +9,10 @@ from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.services.supplier_service import SupplierService
|
||||
from app.models.suppliers import SupplierPriceList
|
||||
from app.schemas.suppliers import (
|
||||
SupplierCreate, SupplierUpdate, SupplierResponse, SupplierSummary,
|
||||
SupplierSearchParams
|
||||
@@ -202,4 +204,54 @@ async def delete_supplier(
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error deleting supplier", supplier_id=str(supplier_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to delete supplier")
|
||||
raise HTTPException(status_code=500, detail="Failed to delete supplier")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_action_route("suppliers", "supplier_id", "products"),
|
||||
response_model=List[Dict[str, Any]]
|
||||
)
|
||||
async def get_supplier_products(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
is_active: bool = Query(True, description="Filter by active price lists"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get list of product IDs that a supplier provides
|
||||
Returns a list of inventory product IDs from the supplier's price list
|
||||
"""
|
||||
try:
|
||||
# Query supplier price lists
|
||||
query = select(SupplierPriceList).where(
|
||||
SupplierPriceList.tenant_id == UUID(tenant_id),
|
||||
SupplierPriceList.supplier_id == supplier_id
|
||||
)
|
||||
|
||||
if is_active:
|
||||
query = query.where(SupplierPriceList.is_active == True)
|
||||
|
||||
result = await db.execute(query)
|
||||
price_lists = result.scalars().all()
|
||||
|
||||
# Extract unique product IDs
|
||||
product_ids = list(set([str(pl.inventory_product_id) for pl in price_lists]))
|
||||
|
||||
logger.info(
|
||||
"Retrieved supplier products",
|
||||
supplier_id=str(supplier_id),
|
||||
product_count=len(product_ids)
|
||||
)
|
||||
|
||||
return [{"inventory_product_id": pid} for pid in product_ids]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting supplier products",
|
||||
supplier_id=str(supplier_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to retrieve supplier products"
|
||||
)
|
||||
@@ -145,7 +145,17 @@ class Supplier(Base):
|
||||
delivery_rating = Column(Float, nullable=True, default=0.0) # Average delivery rating (1-5)
|
||||
total_orders = Column(Integer, nullable=False, default=0)
|
||||
total_amount = Column(Numeric(12, 2), nullable=False, default=0.0)
|
||||
|
||||
|
||||
# Trust and auto-approval metrics
|
||||
trust_score = Column(Float, nullable=False, default=0.0) # Calculated trust score (0.0-1.0)
|
||||
is_preferred_supplier = Column(Boolean, nullable=False, default=False) # Preferred supplier status
|
||||
auto_approve_enabled = Column(Boolean, nullable=False, default=False) # Enable auto-approval for this supplier
|
||||
total_pos_count = Column(Integer, nullable=False, default=0) # Total purchase orders created
|
||||
approved_pos_count = Column(Integer, nullable=False, default=0) # Total POs approved
|
||||
on_time_delivery_rate = Column(Float, nullable=False, default=0.0) # Percentage of on-time deliveries
|
||||
fulfillment_rate = Column(Float, nullable=False, default=0.0) # Percentage of orders fully fulfilled
|
||||
last_performance_update = Column(DateTime(timezone=True), nullable=True) # Last time metrics were calculated
|
||||
|
||||
# Onboarding and approval
|
||||
approved_by = Column(UUID(as_uuid=True), nullable=True) # User who approved
|
||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# services/suppliers/app/repositories/purchase_order_repository.py
|
||||
"""
|
||||
Purchase Order repository for database operations
|
||||
Purchase Order repository for database operations (Async SQLAlchemy 2.0)
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_, or_, func, desc
|
||||
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,
|
||||
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus,
|
||||
Supplier, SupplierPriceList
|
||||
)
|
||||
from app.repositories.base import BaseRepository
|
||||
@@ -18,33 +19,35 @@ from app.repositories.base import BaseRepository
|
||||
|
||||
class PurchaseOrderRepository(BaseRepository[PurchaseOrder]):
|
||||
"""Repository for purchase order management operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
super().__init__(PurchaseOrder, db)
|
||||
|
||||
def get_by_po_number(self, tenant_id: UUID, po_number: str) -> Optional[PurchaseOrder]:
|
||||
|
||||
async 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
|
||||
)
|
||||
stmt = select(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))
|
||||
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)
|
||||
.first()
|
||||
)
|
||||
|
||||
def search_purchase_orders(
|
||||
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,
|
||||
@@ -57,320 +60,196 @@ class PurchaseOrderRepository(BaseRepository[PurchaseOrder]):
|
||||
offset: int = 0
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Search purchase orders with comprehensive filters"""
|
||||
query = (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.supplier))
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.options(selectinload(self.model.supplier))
|
||||
.filter(self.model.tenant_id == tenant_id)
|
||||
)
|
||||
|
||||
|
||||
# Supplier filter
|
||||
if supplier_id:
|
||||
query = query.filter(self.model.supplier_id == supplier_id)
|
||||
|
||||
stmt = stmt.filter(self.model.supplier_id == supplier_id)
|
||||
|
||||
# Status filter
|
||||
if status:
|
||||
query = query.filter(self.model.status == status)
|
||||
|
||||
stmt = stmt.filter(self.model.status == status)
|
||||
|
||||
# Priority filter
|
||||
if priority:
|
||||
query = query.filter(self.model.priority == priority)
|
||||
|
||||
stmt = stmt.filter(self.model.priority == priority)
|
||||
|
||||
# Date range filter
|
||||
if date_from:
|
||||
query = query.filter(self.model.order_date >= date_from)
|
||||
stmt = stmt.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)
|
||||
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}%"),
|
||||
self.model.supplier.has(Supplier.name.ilike(f"%{search_term}%"))
|
||||
self.model.reference_number.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
|
||||
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 all orders with specific status"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
.options(joinedload(self.model.supplier))
|
||||
"""Get purchase orders by status"""
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.filter(
|
||||
and_(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == status
|
||||
)
|
||||
)
|
||||
.order_by(self.model.order_date.desc())
|
||||
.all()
|
||||
.order_by(desc(self.model.order_date))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
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
|
||||
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 recent orders for a specific supplier"""
|
||||
return (
|
||||
self.db.query(self.model)
|
||||
"""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(self.model.order_date.desc())
|
||||
.order_by(desc(self.model.order_date))
|
||||
.limit(limit)
|
||||
.all()
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
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(
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_orders_requiring_delivery(
|
||||
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)
|
||||
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.CONFIRMED,
|
||||
PurchaseOrderStatus.SENT_TO_SUPPLIER,
|
||||
PurchaseOrderStatus.PARTIALLY_RECEIVED
|
||||
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
|
||||
)
|
||||
)
|
||||
.count()
|
||||
.order_by(self.model.required_delivery_date)
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
# services/suppliers/app/repositories/supplier_performance_repository.py
|
||||
"""
|
||||
Supplier Performance Repository - Calculate and manage supplier trust scores
|
||||
Handles supplier performance metrics, trust scores, and auto-approval eligibility
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
import structlog
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, case
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.suppliers import (
|
||||
Supplier,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderStatus
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SupplierPerformanceRepository:
|
||||
"""Repository for calculating and managing supplier performance metrics"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def calculate_trust_score(self, supplier_id: UUID) -> float:
|
||||
"""
|
||||
Calculate comprehensive trust score for a supplier
|
||||
|
||||
Score components (weighted):
|
||||
- Quality rating: 30%
|
||||
- Delivery rating: 30%
|
||||
- On-time delivery rate: 20%
|
||||
- Fulfillment rate: 15%
|
||||
- Order history: 5%
|
||||
|
||||
Returns:
|
||||
float: Trust score between 0.0 and 1.0
|
||||
"""
|
||||
try:
|
||||
# Get supplier with current metrics
|
||||
stmt = select(Supplier).where(Supplier.id == supplier_id)
|
||||
result = await self.db.execute(stmt)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
logger.warning("Supplier not found for trust score calculation", supplier_id=str(supplier_id))
|
||||
return 0.0
|
||||
|
||||
# Calculate on-time delivery rate from recent POs
|
||||
on_time_rate = await self._calculate_on_time_delivery_rate(supplier_id)
|
||||
|
||||
# Calculate fulfillment rate from recent POs
|
||||
fulfillment_rate = await self._calculate_fulfillment_rate(supplier_id)
|
||||
|
||||
# Calculate order history score (more orders = higher confidence)
|
||||
order_history_score = min(1.0, supplier.total_pos_count / 50.0)
|
||||
|
||||
# Weighted components
|
||||
quality_score = (supplier.quality_rating or 0.0) / 5.0 # Normalize to 0-1
|
||||
delivery_score = (supplier.delivery_rating or 0.0) / 5.0 # Normalize to 0-1
|
||||
|
||||
trust_score = (
|
||||
quality_score * 0.30 +
|
||||
delivery_score * 0.30 +
|
||||
on_time_rate * 0.20 +
|
||||
fulfillment_rate * 0.15 +
|
||||
order_history_score * 0.05
|
||||
)
|
||||
|
||||
# Ensure score is between 0 and 1
|
||||
trust_score = max(0.0, min(1.0, trust_score))
|
||||
|
||||
logger.info(
|
||||
"Trust score calculated",
|
||||
supplier_id=str(supplier_id),
|
||||
trust_score=trust_score,
|
||||
quality_score=quality_score,
|
||||
delivery_score=delivery_score,
|
||||
on_time_rate=on_time_rate,
|
||||
fulfillment_rate=fulfillment_rate,
|
||||
order_history_score=order_history_score
|
||||
)
|
||||
|
||||
return trust_score
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error calculating trust score", supplier_id=str(supplier_id), error=str(e))
|
||||
return 0.0
|
||||
|
||||
async def _calculate_on_time_delivery_rate(self, supplier_id: UUID, days: int = 90) -> float:
|
||||
"""Calculate percentage of orders delivered on time in the last N days"""
|
||||
try:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
# Get completed orders with delivery dates
|
||||
stmt = select(
|
||||
func.count(PurchaseOrder.id).label('total_orders'),
|
||||
func.count(
|
||||
case(
|
||||
(PurchaseOrder.actual_delivery_date <= PurchaseOrder.required_delivery_date, 1)
|
||||
)
|
||||
).label('on_time_orders')
|
||||
).where(
|
||||
and_(
|
||||
PurchaseOrder.supplier_id == supplier_id,
|
||||
PurchaseOrder.status == PurchaseOrderStatus.completed,
|
||||
PurchaseOrder.actual_delivery_date.isnot(None),
|
||||
PurchaseOrder.created_at >= cutoff_date
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
row = result.one()
|
||||
|
||||
if row.total_orders == 0:
|
||||
return 0.0
|
||||
|
||||
on_time_rate = float(row.on_time_orders) / float(row.total_orders)
|
||||
return on_time_rate
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error calculating on-time delivery rate", supplier_id=str(supplier_id), error=str(e))
|
||||
return 0.0
|
||||
|
||||
async def _calculate_fulfillment_rate(self, supplier_id: UUID, days: int = 90) -> float:
|
||||
"""Calculate percentage of orders fully fulfilled (no shortages) in the last N days"""
|
||||
try:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
# Get completed/confirmed orders
|
||||
stmt = select(
|
||||
func.count(PurchaseOrder.id).label('total_orders'),
|
||||
func.count(
|
||||
case(
|
||||
(PurchaseOrder.status == PurchaseOrderStatus.completed, 1)
|
||||
)
|
||||
).label('completed_orders')
|
||||
).where(
|
||||
and_(
|
||||
PurchaseOrder.supplier_id == supplier_id,
|
||||
PurchaseOrder.status.in_([
|
||||
PurchaseOrderStatus.completed,
|
||||
PurchaseOrderStatus.partially_received
|
||||
]),
|
||||
PurchaseOrder.created_at >= cutoff_date
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
row = result.one()
|
||||
|
||||
if row.total_orders == 0:
|
||||
return 0.0
|
||||
|
||||
fulfillment_rate = float(row.completed_orders) / float(row.total_orders)
|
||||
return fulfillment_rate
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error calculating fulfillment rate", supplier_id=str(supplier_id), error=str(e))
|
||||
return 0.0
|
||||
|
||||
async def update_supplier_performance_metrics(self, supplier_id: UUID) -> Dict[str, Any]:
|
||||
"""
|
||||
Update all performance metrics for a supplier
|
||||
|
||||
Returns:
|
||||
Dict with updated metrics
|
||||
"""
|
||||
try:
|
||||
# Calculate all metrics
|
||||
trust_score = await self.calculate_trust_score(supplier_id)
|
||||
on_time_rate = await self._calculate_on_time_delivery_rate(supplier_id)
|
||||
fulfillment_rate = await self._calculate_fulfillment_rate(supplier_id)
|
||||
|
||||
# Get current supplier
|
||||
stmt = select(Supplier).where(Supplier.id == supplier_id)
|
||||
result = await self.db.execute(stmt)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
return {}
|
||||
|
||||
# Update supplier metrics
|
||||
supplier.trust_score = trust_score
|
||||
supplier.on_time_delivery_rate = on_time_rate
|
||||
supplier.fulfillment_rate = fulfillment_rate
|
||||
supplier.last_performance_update = datetime.now(timezone.utc)
|
||||
|
||||
# Auto-update preferred status based on performance
|
||||
supplier.is_preferred_supplier = (
|
||||
supplier.total_pos_count >= 10 and
|
||||
trust_score >= 0.80 and
|
||||
supplier.status.value == 'active'
|
||||
)
|
||||
|
||||
# Auto-update auto-approve eligibility
|
||||
supplier.auto_approve_enabled = (
|
||||
supplier.total_pos_count >= 20 and
|
||||
trust_score >= 0.85 and
|
||||
on_time_rate >= 0.90 and
|
||||
supplier.is_preferred_supplier and
|
||||
supplier.status.value == 'active'
|
||||
)
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
logger.info(
|
||||
"Supplier performance metrics updated",
|
||||
supplier_id=str(supplier_id),
|
||||
trust_score=trust_score,
|
||||
is_preferred=supplier.is_preferred_supplier,
|
||||
auto_approve_enabled=supplier.auto_approve_enabled
|
||||
)
|
||||
|
||||
return {
|
||||
"supplier_id": str(supplier_id),
|
||||
"trust_score": trust_score,
|
||||
"on_time_delivery_rate": on_time_rate,
|
||||
"fulfillment_rate": fulfillment_rate,
|
||||
"is_preferred_supplier": supplier.is_preferred_supplier,
|
||||
"auto_approve_enabled": supplier.auto_approve_enabled,
|
||||
"last_updated": supplier.last_performance_update.isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error updating supplier performance metrics", supplier_id=str(supplier_id), error=str(e))
|
||||
raise
|
||||
|
||||
async def increment_po_counters(self, supplier_id: UUID, approved: bool = False):
|
||||
"""Increment PO counters when a new PO is created or approved"""
|
||||
try:
|
||||
stmt = select(Supplier).where(Supplier.id == supplier_id)
|
||||
result = await self.db.execute(stmt)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if supplier:
|
||||
supplier.total_pos_count += 1
|
||||
if approved:
|
||||
supplier.approved_pos_count += 1
|
||||
|
||||
await self.db.commit()
|
||||
logger.info(
|
||||
"Supplier PO counters incremented",
|
||||
supplier_id=str(supplier_id),
|
||||
total=supplier.total_pos_count,
|
||||
approved=supplier.approved_pos_count
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error incrementing PO counters", supplier_id=str(supplier_id), error=str(e))
|
||||
|
||||
async def get_supplier_with_performance(self, supplier_id: UUID) -> Optional[Dict[str, Any]]:
|
||||
"""Get supplier data with all performance metrics"""
|
||||
try:
|
||||
stmt = select(Supplier).where(Supplier.id == supplier_id)
|
||||
result = await self.db.execute(stmt)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": str(supplier.id),
|
||||
"name": supplier.name,
|
||||
"trust_score": supplier.trust_score,
|
||||
"is_preferred_supplier": supplier.is_preferred_supplier,
|
||||
"auto_approve_enabled": supplier.auto_approve_enabled,
|
||||
"total_pos_count": supplier.total_pos_count,
|
||||
"approved_pos_count": supplier.approved_pos_count,
|
||||
"on_time_delivery_rate": supplier.on_time_delivery_rate,
|
||||
"fulfillment_rate": supplier.fulfillment_rate,
|
||||
"quality_rating": supplier.quality_rating,
|
||||
"delivery_rating": supplier.delivery_rating,
|
||||
"status": supplier.status.value if supplier.status else None,
|
||||
"last_performance_update": supplier.last_performance_update.isoformat() if supplier.last_performance_update else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier with performance", supplier_id=str(supplier_id), error=str(e))
|
||||
return None
|
||||
@@ -133,7 +133,7 @@ class PurchaseOrderService:
|
||||
|
||||
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)
|
||||
return await self.repository.get_with_items(po_id)
|
||||
|
||||
async def update_purchase_order(
|
||||
self,
|
||||
@@ -298,7 +298,7 @@ class PurchaseOrderService:
|
||||
search_params: PurchaseOrderSearchParams
|
||||
) -> List[PurchaseOrder]:
|
||||
"""Search purchase orders with filters"""
|
||||
return self.repository.search_purchase_orders(
|
||||
return await self.repository.search_purchase_orders(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=search_params.supplier_id,
|
||||
status=search_params.status,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""add_supplier_trust_metrics
|
||||
|
||||
Revision ID: add_supplier_trust_metrics
|
||||
Revises: 93d6ea3dc888
|
||||
Create Date: 2025-10-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_supplier_trust_metrics'
|
||||
down_revision = '93d6ea3dc888'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add trust and auto-approval metrics to suppliers table"""
|
||||
|
||||
# Add trust and auto-approval metric columns
|
||||
op.add_column('suppliers', sa.Column('trust_score', sa.Float(), nullable=False, server_default='0.0'))
|
||||
op.add_column('suppliers', sa.Column('is_preferred_supplier', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('suppliers', sa.Column('auto_approve_enabled', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('suppliers', sa.Column('total_pos_count', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.add_column('suppliers', sa.Column('approved_pos_count', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.add_column('suppliers', sa.Column('on_time_delivery_rate', sa.Float(), nullable=False, server_default='0.0'))
|
||||
op.add_column('suppliers', sa.Column('fulfillment_rate', sa.Float(), nullable=False, server_default='0.0'))
|
||||
op.add_column('suppliers', sa.Column('last_performance_update', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# Create index for trust score queries
|
||||
op.create_index('ix_suppliers_trust_score', 'suppliers', ['trust_score'], unique=False)
|
||||
op.create_index('ix_suppliers_preferred', 'suppliers', ['is_preferred_supplier'], unique=False)
|
||||
op.create_index('ix_suppliers_auto_approve', 'suppliers', ['auto_approve_enabled'], unique=False)
|
||||
|
||||
# Update existing active suppliers to have reasonable default trust scores
|
||||
# Suppliers with good ratings and history get higher initial trust
|
||||
op.execute("""
|
||||
UPDATE suppliers
|
||||
SET
|
||||
trust_score = LEAST(1.0, GREATEST(0.0,
|
||||
(COALESCE(quality_rating, 0) / 5.0 * 0.4) +
|
||||
(COALESCE(delivery_rating, 0) / 5.0 * 0.4) +
|
||||
(CASE WHEN total_orders > 10 THEN 0.2 ELSE total_orders / 50.0 END)
|
||||
)),
|
||||
is_preferred_supplier = (
|
||||
total_orders >= 10 AND
|
||||
quality_rating >= 4.0 AND
|
||||
delivery_rating >= 4.0 AND
|
||||
status = 'active'
|
||||
),
|
||||
auto_approve_enabled = (
|
||||
total_orders >= 20 AND
|
||||
quality_rating >= 4.5 AND
|
||||
delivery_rating >= 4.5 AND
|
||||
status = 'active'
|
||||
),
|
||||
total_pos_count = total_orders,
|
||||
approved_pos_count = total_orders,
|
||||
on_time_delivery_rate = COALESCE(delivery_rating / 5.0, 0.0),
|
||||
fulfillment_rate = COALESCE(quality_rating / 5.0, 0.0),
|
||||
last_performance_update = NOW()
|
||||
WHERE status = 'active'
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove trust and auto-approval metrics from suppliers table"""
|
||||
|
||||
# Drop indexes
|
||||
op.drop_index('ix_suppliers_auto_approve', table_name='suppliers')
|
||||
op.drop_index('ix_suppliers_preferred', table_name='suppliers')
|
||||
op.drop_index('ix_suppliers_trust_score', table_name='suppliers')
|
||||
|
||||
# Drop columns
|
||||
op.drop_column('suppliers', 'last_performance_update')
|
||||
op.drop_column('suppliers', 'fulfillment_rate')
|
||||
op.drop_column('suppliers', 'on_time_delivery_rate')
|
||||
op.drop_column('suppliers', 'approved_pos_count')
|
||||
op.drop_column('suppliers', 'total_pos_count')
|
||||
op.drop_column('suppliers', 'auto_approve_enabled')
|
||||
op.drop_column('suppliers', 'is_preferred_supplier')
|
||||
op.drop_column('suppliers', 'trust_score')
|
||||
463
services/suppliers/scripts/demo/seed_demo_purchase_orders.py
Normal file
463
services/suppliers/scripts/demo/seed_demo_purchase_orders.py
Normal file
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Purchase Orders Seeding Script for Suppliers Service
|
||||
Creates realistic PO scenarios in various states for demo purposes
|
||||
|
||||
This script creates:
|
||||
- 3 PENDING_APPROVAL POs (created today, need user action)
|
||||
- 2 APPROVED POs (approved yesterday, in progress)
|
||||
- 1 AUTO_APPROVED PO (small amount, trusted supplier)
|
||||
- 2 COMPLETED POs (delivered last week)
|
||||
- 1 REJECTED PO (quality concerns)
|
||||
- 1 CANCELLED PO (supplier unavailable)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from app.models.suppliers import (
|
||||
Supplier, PurchaseOrder, PurchaseOrderItem,
|
||||
PurchaseOrderStatus, SupplierStatus, SupplierType
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Demo tenant IDs (match those from orders service)
|
||||
DEMO_TENANT_IDS = [
|
||||
uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"), # San Pablo
|
||||
uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # La Espiga
|
||||
]
|
||||
|
||||
# System user ID for auto-approvals
|
||||
SYSTEM_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
async def create_or_get_supplier(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
name: str,
|
||||
supplier_type: SupplierType,
|
||||
trust_score: float = 0.0,
|
||||
is_preferred: bool = False,
|
||||
auto_approve_enabled: bool = False
|
||||
) -> Supplier:
|
||||
"""Create or get a demo supplier"""
|
||||
|
||||
# Check if supplier exists
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.tenant_id == tenant_id,
|
||||
Supplier.name == name
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Create new supplier
|
||||
supplier = Supplier(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
supplier_code=f"SUP-{name[:3].upper()}",
|
||||
supplier_type=supplier_type,
|
||||
status=SupplierStatus.active,
|
||||
contact_person=f"Contact {name}",
|
||||
email=f"contact@{name.lower().replace(' ', '')}.com",
|
||||
phone="+34 91 555 " + str(random.randint(1000, 9999)),
|
||||
city="Madrid",
|
||||
country="España",
|
||||
standard_lead_time=random.randint(1, 3),
|
||||
quality_rating=random.uniform(4.0, 5.0),
|
||||
delivery_rating=random.uniform(4.0, 5.0),
|
||||
total_orders=random.randint(20, 100),
|
||||
total_amount=Decimal(str(random.uniform(10000, 50000))),
|
||||
# Trust metrics
|
||||
trust_score=trust_score,
|
||||
is_preferred_supplier=is_preferred,
|
||||
auto_approve_enabled=auto_approve_enabled,
|
||||
total_pos_count=random.randint(25, 80),
|
||||
approved_pos_count=random.randint(24, 78),
|
||||
on_time_delivery_rate=random.uniform(0.85, 0.98),
|
||||
fulfillment_rate=random.uniform(0.90, 0.99),
|
||||
last_performance_update=datetime.now(timezone.utc),
|
||||
approved_by=SYSTEM_USER_ID,
|
||||
approved_at=datetime.now(timezone.utc) - timedelta(days=30),
|
||||
created_by=SYSTEM_USER_ID,
|
||||
updated_by=SYSTEM_USER_ID
|
||||
)
|
||||
|
||||
db.add(supplier)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"Created supplier: {name}", supplier_id=str(supplier.id))
|
||||
return supplier
|
||||
|
||||
|
||||
async def create_purchase_order(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
supplier: Supplier,
|
||||
status: PurchaseOrderStatus,
|
||||
total_amount: Decimal,
|
||||
created_offset_days: int = 0,
|
||||
auto_approved: bool = False,
|
||||
priority: str = "normal",
|
||||
items_data: list = None
|
||||
) -> PurchaseOrder:
|
||||
"""Create a purchase order with items"""
|
||||
|
||||
created_at = datetime.now(timezone.utc) + timedelta(days=created_offset_days)
|
||||
required_delivery = created_at + timedelta(days=random.randint(3, 7))
|
||||
|
||||
# Generate PO number
|
||||
po_number = f"PO-{datetime.now().year}-{random.randint(1000, 9999)}"
|
||||
|
||||
# Calculate amounts
|
||||
subtotal = total_amount
|
||||
tax_amount = subtotal * Decimal("0.10") # 10% IVA
|
||||
shipping_cost = Decimal(str(random.uniform(0, 20)))
|
||||
total = subtotal + tax_amount + shipping_cost
|
||||
|
||||
# Create PO
|
||||
po = PurchaseOrder(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier.id,
|
||||
po_number=po_number,
|
||||
status=status,
|
||||
priority=priority,
|
||||
order_date=created_at,
|
||||
required_delivery_date=required_delivery,
|
||||
subtotal=subtotal,
|
||||
tax_amount=tax_amount,
|
||||
shipping_cost=shipping_cost,
|
||||
discount_amount=Decimal("0.00"),
|
||||
total_amount=total,
|
||||
notes=f"Auto-generated demo PO from procurement plan" if not auto_approved else f"Auto-approved: Amount €{subtotal:.2f} within threshold",
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
created_by=SYSTEM_USER_ID,
|
||||
updated_by=SYSTEM_USER_ID
|
||||
)
|
||||
|
||||
# Set approval data if approved
|
||||
if status in [PurchaseOrderStatus.approved, PurchaseOrderStatus.sent_to_supplier,
|
||||
PurchaseOrderStatus.confirmed, PurchaseOrderStatus.completed]:
|
||||
po.approved_at = created_at + timedelta(hours=random.randint(1, 6))
|
||||
po.approved_by = SYSTEM_USER_ID if auto_approved else uuid.uuid4()
|
||||
if auto_approved:
|
||||
po.notes = f"{po.notes}\nAuto-approved by system based on trust score and amount"
|
||||
|
||||
# Set sent/confirmed dates
|
||||
if status in [PurchaseOrderStatus.sent_to_supplier, PurchaseOrderStatus.confirmed,
|
||||
PurchaseOrderStatus.completed]:
|
||||
po.sent_to_supplier_at = po.approved_at + timedelta(hours=2)
|
||||
|
||||
if status in [PurchaseOrderStatus.confirmed, PurchaseOrderStatus.completed]:
|
||||
po.supplier_confirmation_date = po.sent_to_supplier_at + timedelta(hours=random.randint(4, 24))
|
||||
|
||||
db.add(po)
|
||||
await db.flush()
|
||||
|
||||
# Create items
|
||||
if not items_data:
|
||||
items_data = [
|
||||
{"name": "Harina de Trigo T55", "quantity": 100, "unit_price": 0.85, "uom": "kg"},
|
||||
{"name": "Levadura Fresca", "quantity": 5, "unit_price": 4.50, "uom": "kg"},
|
||||
{"name": "Sal Marina", "quantity": 10, "unit_price": 1.20, "uom": "kg"}
|
||||
]
|
||||
|
||||
for idx, item_data in enumerate(items_data, 1):
|
||||
ordered_qty = int(item_data["quantity"])
|
||||
unit_price = Decimal(str(item_data["unit_price"]))
|
||||
line_total = Decimal(str(ordered_qty)) * unit_price
|
||||
|
||||
item = PurchaseOrderItem(
|
||||
id=uuid.uuid4(),
|
||||
purchase_order_id=po.id,
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=uuid.uuid4(), # Would link to actual inventory items
|
||||
product_code=f"PROD-{item_data['name'][:3].upper()}",
|
||||
ordered_quantity=ordered_qty,
|
||||
received_quantity=ordered_qty if status == PurchaseOrderStatus.completed else 0,
|
||||
remaining_quantity=0 if status == PurchaseOrderStatus.completed else ordered_qty,
|
||||
unit_price=unit_price,
|
||||
line_total=line_total,
|
||||
unit_of_measure=item_data["uom"],
|
||||
item_notes=f"Demo item: {item_data['name']}"
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
logger.info(f"Created PO: {po_number}", po_id=str(po.id), status=status.value, amount=float(total))
|
||||
return po
|
||||
|
||||
|
||||
async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID):
|
||||
"""Seed purchase orders for a specific tenant"""
|
||||
logger.info("Seeding purchase orders", tenant_id=str(tenant_id))
|
||||
|
||||
# Create/get suppliers with different trust levels
|
||||
supplier_high_trust = await create_or_get_supplier(
|
||||
db, tenant_id, "Panadería Central S.L.",
|
||||
SupplierType.ingredients,
|
||||
trust_score=0.92, is_preferred=True, auto_approve_enabled=True
|
||||
)
|
||||
|
||||
supplier_medium_trust = await create_or_get_supplier(
|
||||
db, tenant_id, "Distribuidora Madrid",
|
||||
SupplierType.ingredients,
|
||||
trust_score=0.75, is_preferred=True, auto_approve_enabled=False
|
||||
)
|
||||
|
||||
supplier_new = await create_or_get_supplier(
|
||||
db, tenant_id, "Nuevos Suministros SA",
|
||||
SupplierType.ingredients,
|
||||
trust_score=0.50, is_preferred=False, auto_approve_enabled=False
|
||||
)
|
||||
|
||||
supplier_packaging = await create_or_get_supplier(
|
||||
db, tenant_id, "Embalajes Premium",
|
||||
SupplierType.packaging,
|
||||
trust_score=0.88, is_preferred=True, auto_approve_enabled=True
|
||||
)
|
||||
|
||||
pos_created = []
|
||||
|
||||
# 1. PENDING_APPROVAL - Critical/Urgent (created today)
|
||||
po1 = await create_purchase_order(
|
||||
db, tenant_id, supplier_medium_trust,
|
||||
PurchaseOrderStatus.pending_approval,
|
||||
Decimal("1234.56"),
|
||||
created_offset_days=0,
|
||||
priority="high",
|
||||
items_data=[
|
||||
{"name": "Harina Integral Ecológica", "quantity": 150, "unit_price": 1.20, "uom": "kg"},
|
||||
{"name": "Semillas de Girasol", "quantity": 20, "unit_price": 3.50, "uom": "kg"},
|
||||
{"name": "Miel de Azahar", "quantity": 10, "unit_price": 8.90, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
pos_created.append(po1)
|
||||
|
||||
# 2. PENDING_APPROVAL - Medium amount, new supplier (created today)
|
||||
po2 = await create_purchase_order(
|
||||
db, tenant_id, supplier_new,
|
||||
PurchaseOrderStatus.pending_approval,
|
||||
Decimal("789.00"),
|
||||
created_offset_days=0,
|
||||
items_data=[
|
||||
{"name": "Aceite de Oliva Virgen", "quantity": 30, "unit_price": 8.50, "uom": "l"},
|
||||
{"name": "Azúcar Moreno", "quantity": 50, "unit_price": 1.80, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
pos_created.append(po2)
|
||||
|
||||
# 3. PENDING_APPROVAL - Large amount (created yesterday)
|
||||
po3 = await create_purchase_order(
|
||||
db, tenant_id, supplier_medium_trust,
|
||||
PurchaseOrderStatus.pending_approval,
|
||||
Decimal("2500.00"),
|
||||
created_offset_days=-1,
|
||||
priority="normal",
|
||||
items_data=[
|
||||
{"name": "Harina de Fuerza T65", "quantity": 500, "unit_price": 0.95, "uom": "kg"},
|
||||
{"name": "Mantequilla Premium", "quantity": 80, "unit_price": 5.20, "uom": "kg"},
|
||||
{"name": "Huevos Categoría A", "quantity": 600, "unit_price": 0.22, "uom": "unidad"}
|
||||
]
|
||||
)
|
||||
pos_created.append(po3)
|
||||
|
||||
# 4. APPROVED (auto-approved, small amount, trusted supplier)
|
||||
po4 = await create_purchase_order(
|
||||
db, tenant_id, supplier_high_trust,
|
||||
PurchaseOrderStatus.approved,
|
||||
Decimal("234.50"),
|
||||
created_offset_days=0,
|
||||
auto_approved=True,
|
||||
items_data=[
|
||||
{"name": "Levadura Seca", "quantity": 5, "unit_price": 6.90, "uom": "kg"},
|
||||
{"name": "Sal Fina", "quantity": 25, "unit_price": 0.85, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
pos_created.append(po4)
|
||||
|
||||
# 5. APPROVED (manually approved yesterday)
|
||||
po5 = await create_purchase_order(
|
||||
db, tenant_id, supplier_packaging,
|
||||
PurchaseOrderStatus.approved,
|
||||
Decimal("456.78"),
|
||||
created_offset_days=-1,
|
||||
items_data=[
|
||||
{"name": "Bolsas de Papel Kraft", "quantity": 1000, "unit_price": 0.12, "uom": "unidad"},
|
||||
{"name": "Cajas de Cartón Grande", "quantity": 200, "unit_price": 0.45, "uom": "unidad"}
|
||||
]
|
||||
)
|
||||
pos_created.append(po5)
|
||||
|
||||
# 6. COMPLETED (delivered last week)
|
||||
po6 = await create_purchase_order(
|
||||
db, tenant_id, supplier_high_trust,
|
||||
PurchaseOrderStatus.completed,
|
||||
Decimal("1567.80"),
|
||||
created_offset_days=-7,
|
||||
items_data=[
|
||||
{"name": "Harina T55 Premium", "quantity": 300, "unit_price": 0.90, "uom": "kg"},
|
||||
{"name": "Chocolate Negro 70%", "quantity": 40, "unit_price": 7.80, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
pos_created.append(po6)
|
||||
|
||||
# 7. COMPLETED (delivered 5 days ago)
|
||||
po7 = await create_purchase_order(
|
||||
db, tenant_id, supplier_medium_trust,
|
||||
PurchaseOrderStatus.completed,
|
||||
Decimal("890.45"),
|
||||
created_offset_days=-5,
|
||||
items_data=[
|
||||
{"name": "Nueces Peladas", "quantity": 20, "unit_price": 12.50, "uom": "kg"},
|
||||
{"name": "Pasas Sultanas", "quantity": 15, "unit_price": 4.30, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
pos_created.append(po7)
|
||||
|
||||
# 8. CANCELLED (supplier unavailable)
|
||||
po8 = await create_purchase_order(
|
||||
db, tenant_id, supplier_new,
|
||||
PurchaseOrderStatus.cancelled,
|
||||
Decimal("345.00"),
|
||||
created_offset_days=-3,
|
||||
items_data=[
|
||||
{"name": "Avellanas Tostadas", "quantity": 25, "unit_price": 11.80, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
po8.rejection_reason = "Supplier unable to deliver - stock unavailable"
|
||||
po8.notes = "Cancelled: Supplier stock unavailable at required delivery date"
|
||||
pos_created.append(po8)
|
||||
|
||||
# 9. DISPUTED (quality issues)
|
||||
po9 = await create_purchase_order(
|
||||
db, tenant_id, supplier_medium_trust,
|
||||
PurchaseOrderStatus.disputed,
|
||||
Decimal("678.90"),
|
||||
created_offset_days=-4,
|
||||
priority="high",
|
||||
items_data=[
|
||||
{"name": "Cacao en Polvo", "quantity": 30, "unit_price": 18.50, "uom": "kg"},
|
||||
{"name": "Vainilla en Rama", "quantity": 2, "unit_price": 45.20, "uom": "kg"}
|
||||
]
|
||||
)
|
||||
po9.rejection_reason = "Quality below specifications - requesting replacement"
|
||||
po9.notes = "DISPUTED: Quality issue reported - batch rejected, requesting replacement or refund"
|
||||
pos_created.append(po9)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Successfully created {len(pos_created)} purchase orders for tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
pending_approval=3,
|
||||
approved=2,
|
||||
completed=2,
|
||||
cancelled=1,
|
||||
disputed=1
|
||||
)
|
||||
|
||||
return pos_created
|
||||
|
||||
|
||||
async def seed_all(db: AsyncSession):
|
||||
"""Seed all demo tenants with purchase orders"""
|
||||
logger.info("Starting demo purchase orders seed process")
|
||||
|
||||
all_pos = []
|
||||
|
||||
for tenant_id in DEMO_TENANT_IDS:
|
||||
# Check if POs already exist
|
||||
result = await db.execute(
|
||||
select(PurchaseOrder).where(PurchaseOrder.tenant_id == tenant_id).limit(1)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(f"Purchase orders already exist for tenant {tenant_id}, skipping")
|
||||
continue
|
||||
|
||||
pos = await seed_purchase_orders_for_tenant(db, tenant_id)
|
||||
all_pos.extend(pos)
|
||||
|
||||
return {
|
||||
"total_pos_created": len(all_pos),
|
||||
"tenants_seeded": len(DEMO_TENANT_IDS),
|
||||
"status": "completed"
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main execution function"""
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("SUPPLIERS_DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("SUPPLIERS_DATABASE_URL environment variable must be set")
|
||||
return 1
|
||||
|
||||
# Ensure asyncpg driver
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(database_url, echo=False)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await seed_all(session)
|
||||
|
||||
logger.info(
|
||||
"Purchase orders seed completed successfully!",
|
||||
total_pos=result["total_pos_created"],
|
||||
tenants=result["tenants_seeded"]
|
||||
)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "="*60)
|
||||
print("DEMO PURCHASE ORDERS SEED SUMMARY")
|
||||
print("="*60)
|
||||
print(f"Total POs Created: {result['total_pos_created']}")
|
||||
print(f"Tenants Seeded: {result['tenants_seeded']}")
|
||||
print("\nPO Distribution:")
|
||||
print(" - 3 PENDING_APPROVAL (need user action)")
|
||||
print(" - 2 APPROVED (in progress)")
|
||||
print(" - 2 COMPLETED (delivered)")
|
||||
print(" - 1 CANCELLED (supplier issue)")
|
||||
print(" - 1 DISPUTED (quality issue)")
|
||||
print("="*60 + "\n")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Purchase orders seed failed: {str(e)}", exc_info=True)
|
||||
return 1
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -167,62 +167,62 @@ async def seed_suppliers_for_tenant(
|
||||
existing_supplier = result.scalars().first()
|
||||
|
||||
if existing_supplier:
|
||||
logger.debug(f" ⏭️ Skipping supplier (exists): {supplier_name}")
|
||||
logger.debug(f" ⏭️ Supplier exists, ensuring price lists: {supplier_name}")
|
||||
skipped_suppliers += 1
|
||||
continue
|
||||
# Don't skip - continue to create/update price lists below
|
||||
else:
|
||||
# Parse enums
|
||||
try:
|
||||
supplier_type = SupplierType(supplier_data.get("supplier_type", "ingredients"))
|
||||
except ValueError:
|
||||
supplier_type = SupplierType.INGREDIENTS
|
||||
|
||||
# Parse enums
|
||||
try:
|
||||
supplier_type = SupplierType(supplier_data.get("supplier_type", "ingredients"))
|
||||
except ValueError:
|
||||
supplier_type = SupplierType.INGREDIENTS
|
||||
try:
|
||||
status = SupplierStatus(supplier_data.get("status", "active"))
|
||||
except ValueError:
|
||||
status = SupplierStatus.ACTIVE
|
||||
|
||||
try:
|
||||
status = SupplierStatus(supplier_data.get("status", "active"))
|
||||
except ValueError:
|
||||
status = SupplierStatus.ACTIVE
|
||||
try:
|
||||
payment_terms = PaymentTerms(supplier_data.get("payment_terms", "net_30"))
|
||||
except ValueError:
|
||||
payment_terms = PaymentTerms.NET_30
|
||||
|
||||
try:
|
||||
payment_terms = PaymentTerms(supplier_data.get("payment_terms", "net_30"))
|
||||
except ValueError:
|
||||
payment_terms = PaymentTerms.NET_30
|
||||
# Create supplier with pre-defined ID
|
||||
supplier = Supplier(
|
||||
id=supplier_id,
|
||||
tenant_id=tenant_id,
|
||||
name=supplier_name,
|
||||
supplier_code=f"SUP-{created_suppliers + 1:03d}",
|
||||
supplier_type=supplier_type,
|
||||
status=status,
|
||||
tax_id=supplier_data.get("tax_id"),
|
||||
contact_person=supplier_data.get("contact_person"),
|
||||
email=supplier_data.get("email"),
|
||||
phone=supplier_data.get("phone"),
|
||||
mobile=supplier_data.get("mobile"),
|
||||
website=supplier_data.get("website"),
|
||||
address_line1=supplier_data.get("address_line1"),
|
||||
address_line2=supplier_data.get("address_line2"),
|
||||
city=supplier_data.get("city"),
|
||||
state_province=supplier_data.get("state_province"),
|
||||
postal_code=supplier_data.get("postal_code"),
|
||||
country=supplier_data.get("country", "España"),
|
||||
payment_terms=payment_terms,
|
||||
credit_limit=Decimal(str(supplier_data.get("credit_limit", 0.0))),
|
||||
standard_lead_time=supplier_data.get("standard_lead_time", 3),
|
||||
quality_rating=supplier_data.get("quality_rating", 4.5),
|
||||
delivery_rating=supplier_data.get("delivery_rating", 4.5),
|
||||
notes=supplier_data.get("notes"),
|
||||
certifications=supplier_data.get("certifications", []),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
created_by=uuid.UUID("00000000-0000-0000-0000-000000000000"), # System user
|
||||
updated_by=uuid.UUID("00000000-0000-0000-0000-000000000000") # System user
|
||||
)
|
||||
|
||||
# Create supplier with pre-defined ID
|
||||
supplier = Supplier(
|
||||
id=supplier_id,
|
||||
tenant_id=tenant_id,
|
||||
name=supplier_name,
|
||||
supplier_code=f"SUP-{created_suppliers + 1:03d}",
|
||||
supplier_type=supplier_type,
|
||||
status=status,
|
||||
tax_id=supplier_data.get("tax_id"),
|
||||
contact_person=supplier_data.get("contact_person"),
|
||||
email=supplier_data.get("email"),
|
||||
phone=supplier_data.get("phone"),
|
||||
mobile=supplier_data.get("mobile"),
|
||||
website=supplier_data.get("website"),
|
||||
address_line1=supplier_data.get("address_line1"),
|
||||
address_line2=supplier_data.get("address_line2"),
|
||||
city=supplier_data.get("city"),
|
||||
state_province=supplier_data.get("state_province"),
|
||||
postal_code=supplier_data.get("postal_code"),
|
||||
country=supplier_data.get("country", "España"),
|
||||
payment_terms=payment_terms,
|
||||
credit_limit=Decimal(str(supplier_data.get("credit_limit", 0.0))),
|
||||
standard_lead_time=supplier_data.get("standard_lead_time", 3),
|
||||
quality_rating=supplier_data.get("quality_rating", 4.5),
|
||||
delivery_rating=supplier_data.get("delivery_rating", 4.5),
|
||||
notes=supplier_data.get("notes"),
|
||||
certifications=supplier_data.get("certifications", []),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
created_by=uuid.UUID("00000000-0000-0000-0000-000000000000"), # System user
|
||||
updated_by=uuid.UUID("00000000-0000-0000-0000-000000000000") # System user
|
||||
)
|
||||
|
||||
db.add(supplier)
|
||||
created_suppliers += 1
|
||||
logger.debug(f" ✅ Created supplier: {supplier_name}")
|
||||
db.add(supplier)
|
||||
created_suppliers += 1
|
||||
logger.debug(f" ✅ Created supplier: {supplier_name}")
|
||||
|
||||
# Create price lists for products using pre-defined ingredient IDs
|
||||
products = supplier_data.get("products", [])
|
||||
@@ -238,6 +238,20 @@ async def seed_suppliers_for_tenant(
|
||||
tenant_int = int(tenant_id.hex, 16)
|
||||
ingredient_id = uuid.UUID(int=tenant_int ^ int(base_ingredient_id.hex, 16))
|
||||
|
||||
# Check if price list already exists
|
||||
existing_price_list_result = await db.execute(
|
||||
select(SupplierPriceList).where(
|
||||
SupplierPriceList.tenant_id == tenant_id,
|
||||
SupplierPriceList.supplier_id == supplier_id,
|
||||
SupplierPriceList.inventory_product_id == ingredient_id
|
||||
)
|
||||
)
|
||||
existing_price_list = existing_price_list_result.scalars().first()
|
||||
|
||||
if existing_price_list:
|
||||
# Price list already exists, skip
|
||||
continue
|
||||
|
||||
# Get base cost from hardcoded costs
|
||||
base_cost = INGREDIENT_COSTS.get(product_sku, 1.0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user