2025-12-05 20:07:01 +01:00
|
|
|
"""
|
|
|
|
|
Expected Deliveries API for Procurement Service
|
|
|
|
|
Public endpoint for expected delivery tracking
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
import structlog
|
|
|
|
|
import uuid
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
from typing import Optional, List
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
from app.core.database import get_db
|
|
|
|
|
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus
|
|
|
|
|
from shared.auth.decorators import get_current_user_dep
|
|
|
|
|
from shared.routing import RouteBuilder
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
route_builder = RouteBuilder('procurement')
|
|
|
|
|
router = APIRouter(tags=["expected-deliveries"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_base_route("expected-deliveries")
|
|
|
|
|
)
|
|
|
|
|
async def get_expected_deliveries(
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
days_ahead: int = Query(1, description="Number of days to look ahead", ge=0, le=30),
|
|
|
|
|
include_overdue: bool = Query(True, description="Include overdue deliveries"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Get expected deliveries for delivery tracking system.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant UUID to query
|
|
|
|
|
days_ahead: Number of days to look ahead (default 1 = today + tomorrow)
|
|
|
|
|
include_overdue: Include deliveries past expected date (default True)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
{
|
|
|
|
|
"deliveries": [
|
|
|
|
|
{
|
|
|
|
|
"po_id": "uuid",
|
|
|
|
|
"po_number": "PO-2025-123",
|
|
|
|
|
"supplier_id": "uuid",
|
|
|
|
|
"supplier_name": "Molinos San José",
|
|
|
|
|
"supplier_phone": "+34 915 234 567",
|
|
|
|
|
"expected_delivery_date": "2025-12-02T10:00:00Z",
|
|
|
|
|
"delivery_window_hours": 4,
|
|
|
|
|
"status": "sent_to_supplier",
|
|
|
|
|
"line_items": [...],
|
|
|
|
|
"total_amount": 540.00,
|
|
|
|
|
"currency": "EUR"
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"total_count": 8
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Parse tenant_id
|
|
|
|
|
tenant_uuid = uuid.UUID(tenant_id)
|
|
|
|
|
|
|
|
|
|
# Calculate date range
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
end_date = now + timedelta(days=days_ahead)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Fetching expected deliveries",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
days_ahead=days_ahead,
|
|
|
|
|
include_overdue=include_overdue
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Build query for purchase orders with expected delivery dates
|
|
|
|
|
query = select(PurchaseOrder).options(
|
|
|
|
|
selectinload(PurchaseOrder.items)
|
|
|
|
|
).where(
|
|
|
|
|
PurchaseOrder.tenant_id == tenant_uuid,
|
|
|
|
|
PurchaseOrder.expected_delivery_date.isnot(None),
|
|
|
|
|
PurchaseOrder.status.in_([
|
|
|
|
|
PurchaseOrderStatus.approved,
|
|
|
|
|
PurchaseOrderStatus.sent_to_supplier,
|
|
|
|
|
PurchaseOrderStatus.confirmed
|
|
|
|
|
])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Add date filters
|
|
|
|
|
if include_overdue:
|
2025-12-13 23:57:54 +01:00
|
|
|
# Include deliveries from last 48 hours (recent overdue) until end_date
|
|
|
|
|
# This ensures we only show truly recent overdue deliveries, not ancient history
|
|
|
|
|
start_date = now - timedelta(hours=48)
|
2025-12-05 20:07:01 +01:00
|
|
|
query = query.where(
|
2025-12-13 23:57:54 +01:00
|
|
|
PurchaseOrder.expected_delivery_date >= start_date,
|
2025-12-05 20:07:01 +01:00
|
|
|
PurchaseOrder.expected_delivery_date <= end_date
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Only future deliveries within range
|
|
|
|
|
query = query.where(
|
|
|
|
|
PurchaseOrder.expected_delivery_date >= now,
|
|
|
|
|
PurchaseOrder.expected_delivery_date <= end_date
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Order by delivery date
|
|
|
|
|
query = query.order_by(PurchaseOrder.expected_delivery_date.asc())
|
|
|
|
|
|
|
|
|
|
# Execute query
|
|
|
|
|
result = await db.execute(query)
|
|
|
|
|
purchase_orders = result.scalars().all()
|
|
|
|
|
|
|
|
|
|
# Format deliveries for response
|
|
|
|
|
deliveries = []
|
|
|
|
|
|
|
|
|
|
for po in purchase_orders:
|
|
|
|
|
# Get supplier info from supplier service (for now, use supplier_id)
|
|
|
|
|
# In production, you'd fetch from supplier service or join if same DB
|
|
|
|
|
supplier_name = f"Supplier-{str(po.supplier_id)[:8]}"
|
|
|
|
|
supplier_phone = None
|
|
|
|
|
|
|
|
|
|
# Try to get supplier details from notes or metadata
|
|
|
|
|
# This is a simplified approach - in production you'd query supplier service
|
|
|
|
|
if po.notes:
|
|
|
|
|
if "Molinos San José" in po.notes:
|
|
|
|
|
supplier_name = "Molinos San José S.L."
|
|
|
|
|
supplier_phone = "+34 915 234 567"
|
|
|
|
|
elif "Lácteos del Valle" in po.notes:
|
|
|
|
|
supplier_name = "Lácteos del Valle S.A."
|
|
|
|
|
supplier_phone = "+34 913 456 789"
|
|
|
|
|
elif "Chocolates Valor" in po.notes:
|
|
|
|
|
supplier_name = "Chocolates Valor"
|
|
|
|
|
supplier_phone = "+34 965 510 062"
|
|
|
|
|
elif "Suministros Hostelería" in po.notes:
|
|
|
|
|
supplier_name = "Suministros Hostelería"
|
|
|
|
|
supplier_phone = "+34 911 234 567"
|
|
|
|
|
elif "Miel Artesana" in po.notes:
|
|
|
|
|
supplier_name = "Miel Artesana"
|
|
|
|
|
supplier_phone = "+34 918 765 432"
|
|
|
|
|
|
|
|
|
|
# Format line items (limit to first 5)
|
|
|
|
|
line_items = []
|
|
|
|
|
for item in po.items[:5]:
|
|
|
|
|
line_items.append({
|
|
|
|
|
"product_name": item.product_name,
|
|
|
|
|
"quantity": float(item.ordered_quantity) if item.ordered_quantity else 0,
|
|
|
|
|
"unit": item.unit_of_measure or "unit"
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Default delivery window is 4 hours
|
|
|
|
|
delivery_window_hours = 4
|
|
|
|
|
|
2025-12-13 23:57:54 +01:00
|
|
|
# Ensure expected delivery date is timezone-aware and in UTC format
|
|
|
|
|
expected_delivery_utc = po.expected_delivery_date
|
|
|
|
|
if expected_delivery_utc and expected_delivery_utc.tzinfo is None:
|
|
|
|
|
# If naive datetime, assume it's UTC (this shouldn't happen with proper DB setup)
|
|
|
|
|
expected_delivery_utc = expected_delivery_utc.replace(tzinfo=timezone.utc)
|
|
|
|
|
elif expected_delivery_utc and expected_delivery_utc.tzinfo is not None:
|
|
|
|
|
# Convert to UTC if it's in another timezone
|
|
|
|
|
expected_delivery_utc = expected_delivery_utc.astimezone(timezone.utc)
|
|
|
|
|
|
2025-12-05 20:07:01 +01:00
|
|
|
delivery_dict = {
|
|
|
|
|
"po_id": str(po.id),
|
|
|
|
|
"po_number": po.po_number,
|
|
|
|
|
"supplier_id": str(po.supplier_id),
|
|
|
|
|
"supplier_name": supplier_name,
|
|
|
|
|
"supplier_phone": supplier_phone,
|
2025-12-13 23:57:54 +01:00
|
|
|
"expected_delivery_date": expected_delivery_utc.isoformat() if expected_delivery_utc else None,
|
2025-12-05 20:07:01 +01:00
|
|
|
"delivery_window_hours": delivery_window_hours,
|
|
|
|
|
"status": po.status.value,
|
|
|
|
|
"line_items": line_items,
|
|
|
|
|
"total_amount": float(po.total_amount) if po.total_amount else 0.0,
|
|
|
|
|
"currency": po.currency
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deliveries.append(delivery_dict)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Expected deliveries retrieved",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
count=len(deliveries)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"deliveries": deliveries,
|
|
|
|
|
"total_count": len(deliveries)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
logger.error("Invalid UUID format", error=str(e), tenant_id=tenant_id)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Invalid UUID: {tenant_id}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Error fetching expected deliveries",
|
|
|
|
|
error=str(e),
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
exc_info=True
|
|
|
|
|
)
|
2025-12-13 23:57:54 +01:00
|
|
|
raise HTTPException(status_code=500, detail="Internal server error")
|