Improve the frontend 3
This commit is contained in:
13
services/procurement/app/api/__init__.py
Normal file
13
services/procurement/app/api/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Procurement Service API"""
|
||||
|
||||
from .procurement_plans import router as procurement_plans_router
|
||||
from .purchase_orders import router as purchase_orders_router
|
||||
from .replenishment import router as replenishment_router
|
||||
from .internal_demo import router as internal_demo_router
|
||||
|
||||
__all__ = [
|
||||
"procurement_plans_router",
|
||||
"purchase_orders_router",
|
||||
"replenishment_router",
|
||||
"internal_demo_router"
|
||||
]
|
||||
523
services/procurement/app/api/internal_demo.py
Normal file
523
services/procurement/app/api/internal_demo.py
Normal file
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
Internal Demo Cloning API for Procurement Service
|
||||
Service-to-service endpoint for cloning procurement and purchase order data
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
import structlog
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from typing import Optional
|
||||
import os
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
|
||||
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem
|
||||
from app.models.replenishment import ReplenishmentPlan, ReplenishmentPlanItem
|
||||
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal/demo", tags=["internal"])
|
||||
|
||||
# Internal API key for service-to-service auth
|
||||
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
|
||||
|
||||
# Base demo tenant IDs
|
||||
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
||||
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
|
||||
|
||||
|
||||
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
if x_internal_api_key != INTERNAL_API_KEY:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/clone")
|
||||
async def clone_demo_data(
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
demo_account_type: str,
|
||||
session_id: Optional[str] = None,
|
||||
session_created_at: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Clone procurement service data for a virtual demo tenant
|
||||
|
||||
Clones:
|
||||
- Procurement plans with requirements
|
||||
- Purchase orders with line items
|
||||
- Replenishment plans with items
|
||||
- Adjusts dates to recent timeframe
|
||||
|
||||
Args:
|
||||
base_tenant_id: Template tenant UUID to clone from
|
||||
virtual_tenant_id: Target virtual tenant UUID
|
||||
demo_account_type: Type of demo account
|
||||
session_id: Originating session ID for tracing
|
||||
|
||||
Returns:
|
||||
Cloning status and record counts
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Parse session creation time for date adjustment
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
session_time = start_time
|
||||
else:
|
||||
session_time = start_time
|
||||
|
||||
logger.info(
|
||||
"Starting procurement data cloning",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
demo_account_type=demo_account_type,
|
||||
session_id=session_id,
|
||||
session_created_at=session_created_at
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate UUIDs
|
||||
base_uuid = uuid.UUID(base_tenant_id)
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
|
||||
# Track cloning statistics
|
||||
stats = {
|
||||
"procurement_plans": 0,
|
||||
"procurement_requirements": 0,
|
||||
"purchase_orders": 0,
|
||||
"purchase_order_items": 0,
|
||||
"replenishment_plans": 0,
|
||||
"replenishment_items": 0
|
||||
}
|
||||
|
||||
# Clone Procurement Plans with Requirements
|
||||
result = await db.execute(
|
||||
select(ProcurementPlan).where(ProcurementPlan.tenant_id == base_uuid)
|
||||
)
|
||||
base_plans = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Found procurement plans to clone",
|
||||
count=len(base_plans),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
|
||||
# Calculate date offset for procurement
|
||||
if base_plans:
|
||||
max_plan_date = max(plan.plan_date for plan in base_plans if plan.plan_date)
|
||||
today_date = date.today()
|
||||
days_diff = (today_date - max_plan_date).days
|
||||
plan_date_offset = timedelta(days=days_diff)
|
||||
else:
|
||||
plan_date_offset = timedelta(days=0)
|
||||
|
||||
plan_id_map = {}
|
||||
|
||||
for plan in base_plans:
|
||||
new_plan_id = uuid.uuid4()
|
||||
plan_id_map[plan.id] = new_plan_id
|
||||
|
||||
new_plan = ProcurementPlan(
|
||||
id=new_plan_id,
|
||||
tenant_id=virtual_uuid,
|
||||
plan_number=f"PROC-{uuid.uuid4().hex[:8].upper()}",
|
||||
plan_date=plan.plan_date + plan_date_offset if plan.plan_date else None,
|
||||
plan_period_start=plan.plan_period_start + plan_date_offset if plan.plan_period_start else None,
|
||||
plan_period_end=plan.plan_period_end + plan_date_offset if plan.plan_period_end else None,
|
||||
planning_horizon_days=plan.planning_horizon_days,
|
||||
status=plan.status,
|
||||
plan_type=plan.plan_type,
|
||||
priority=plan.priority,
|
||||
business_model=plan.business_model,
|
||||
procurement_strategy=plan.procurement_strategy,
|
||||
total_requirements=plan.total_requirements,
|
||||
total_estimated_cost=plan.total_estimated_cost,
|
||||
total_approved_cost=plan.total_approved_cost,
|
||||
cost_variance=plan.cost_variance,
|
||||
created_at=session_time,
|
||||
updated_at=session_time
|
||||
)
|
||||
db.add(new_plan)
|
||||
stats["procurement_plans"] += 1
|
||||
|
||||
# Clone Procurement Requirements
|
||||
for old_plan_id, new_plan_id in plan_id_map.items():
|
||||
result = await db.execute(
|
||||
select(ProcurementRequirement).where(ProcurementRequirement.plan_id == old_plan_id)
|
||||
)
|
||||
requirements = result.scalars().all()
|
||||
|
||||
for req in requirements:
|
||||
new_req = ProcurementRequirement(
|
||||
id=uuid.uuid4(),
|
||||
plan_id=new_plan_id,
|
||||
requirement_number=req.requirement_number,
|
||||
product_id=req.product_id,
|
||||
product_name=req.product_name,
|
||||
product_sku=req.product_sku,
|
||||
product_category=req.product_category,
|
||||
product_type=req.product_type,
|
||||
required_quantity=req.required_quantity,
|
||||
unit_of_measure=req.unit_of_measure,
|
||||
safety_stock_quantity=req.safety_stock_quantity,
|
||||
total_quantity_needed=req.total_quantity_needed,
|
||||
current_stock_level=req.current_stock_level,
|
||||
reserved_stock=req.reserved_stock,
|
||||
available_stock=req.available_stock,
|
||||
net_requirement=req.net_requirement,
|
||||
order_demand=req.order_demand,
|
||||
production_demand=req.production_demand,
|
||||
forecast_demand=req.forecast_demand,
|
||||
buffer_demand=req.buffer_demand,
|
||||
preferred_supplier_id=req.preferred_supplier_id,
|
||||
backup_supplier_id=req.backup_supplier_id,
|
||||
supplier_name=req.supplier_name,
|
||||
supplier_lead_time_days=req.supplier_lead_time_days,
|
||||
minimum_order_quantity=req.minimum_order_quantity,
|
||||
estimated_unit_cost=req.estimated_unit_cost,
|
||||
estimated_total_cost=req.estimated_total_cost,
|
||||
last_purchase_cost=req.last_purchase_cost,
|
||||
cost_variance=req.cost_variance,
|
||||
required_by_date=req.required_by_date + plan_date_offset if req.required_by_date else None,
|
||||
lead_time_buffer_days=req.lead_time_buffer_days,
|
||||
suggested_order_date=req.suggested_order_date + plan_date_offset if req.suggested_order_date else None,
|
||||
latest_order_date=req.latest_order_date + plan_date_offset if req.latest_order_date else None,
|
||||
quality_specifications=req.quality_specifications,
|
||||
special_requirements=req.special_requirements,
|
||||
storage_requirements=req.storage_requirements,
|
||||
shelf_life_days=req.shelf_life_days,
|
||||
status=req.status,
|
||||
priority=req.priority,
|
||||
risk_level=req.risk_level,
|
||||
purchase_order_id=req.purchase_order_id,
|
||||
purchase_order_number=req.purchase_order_number,
|
||||
ordered_quantity=req.ordered_quantity,
|
||||
ordered_at=req.ordered_at,
|
||||
expected_delivery_date=req.expected_delivery_date + plan_date_offset if req.expected_delivery_date else None,
|
||||
actual_delivery_date=req.actual_delivery_date + plan_date_offset if req.actual_delivery_date else None,
|
||||
received_quantity=req.received_quantity,
|
||||
delivery_status=req.delivery_status,
|
||||
fulfillment_rate=req.fulfillment_rate,
|
||||
on_time_delivery=req.on_time_delivery,
|
||||
quality_rating=req.quality_rating,
|
||||
source_orders=req.source_orders,
|
||||
source_production_batches=req.source_production_batches,
|
||||
demand_analysis=req.demand_analysis,
|
||||
approved_quantity=req.approved_quantity,
|
||||
approved_cost=req.approved_cost,
|
||||
approved_at=req.approved_at,
|
||||
approved_by=req.approved_by,
|
||||
procurement_notes=req.procurement_notes,
|
||||
supplier_communication=req.supplier_communication,
|
||||
requirement_metadata=req.requirement_metadata,
|
||||
created_at=session_time,
|
||||
updated_at=session_time
|
||||
)
|
||||
db.add(new_req)
|
||||
stats["procurement_requirements"] += 1
|
||||
|
||||
# Clone Purchase Orders with Line Items
|
||||
result = await db.execute(
|
||||
select(PurchaseOrder).where(PurchaseOrder.tenant_id == base_uuid)
|
||||
)
|
||||
base_orders = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Found purchase orders to clone",
|
||||
count=len(base_orders),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
|
||||
order_id_map = {}
|
||||
|
||||
for order in base_orders:
|
||||
new_order_id = uuid.uuid4()
|
||||
order_id_map[order.id] = new_order_id
|
||||
|
||||
# Adjust dates using demo_dates utility
|
||||
adjusted_order_date = adjust_date_for_demo(
|
||||
order.order_date, session_time, BASE_REFERENCE_DATE
|
||||
)
|
||||
adjusted_required_delivery = adjust_date_for_demo(
|
||||
order.required_delivery_date, session_time, BASE_REFERENCE_DATE
|
||||
)
|
||||
adjusted_estimated_delivery = adjust_date_for_demo(
|
||||
order.estimated_delivery_date, session_time, BASE_REFERENCE_DATE
|
||||
)
|
||||
adjusted_supplier_confirmation = adjust_date_for_demo(
|
||||
order.supplier_confirmation_date, session_time, BASE_REFERENCE_DATE
|
||||
)
|
||||
adjusted_approved_at = adjust_date_for_demo(
|
||||
order.approved_at, session_time, BASE_REFERENCE_DATE
|
||||
)
|
||||
adjusted_sent_to_supplier_at = adjust_date_for_demo(
|
||||
order.sent_to_supplier_at, session_time, BASE_REFERENCE_DATE
|
||||
)
|
||||
|
||||
# Generate a system user UUID for audit fields (demo purposes)
|
||||
system_user_id = uuid.uuid4()
|
||||
|
||||
new_order = PurchaseOrder(
|
||||
id=new_order_id,
|
||||
tenant_id=virtual_uuid,
|
||||
po_number=f"PO-{uuid.uuid4().hex[:8].upper()}", # New PO number
|
||||
reference_number=order.reference_number,
|
||||
supplier_id=order.supplier_id,
|
||||
procurement_plan_id=plan_id_map.get(order.procurement_plan_id) if hasattr(order, 'procurement_plan_id') and order.procurement_plan_id else None,
|
||||
order_date=adjusted_order_date,
|
||||
required_delivery_date=adjusted_required_delivery,
|
||||
estimated_delivery_date=adjusted_estimated_delivery,
|
||||
status=order.status,
|
||||
priority=order.priority,
|
||||
subtotal=order.subtotal,
|
||||
tax_amount=order.tax_amount,
|
||||
discount_amount=order.discount_amount,
|
||||
shipping_cost=order.shipping_cost,
|
||||
total_amount=order.total_amount,
|
||||
currency=order.currency,
|
||||
delivery_address=order.delivery_address if hasattr(order, 'delivery_address') else None,
|
||||
delivery_instructions=order.delivery_instructions if hasattr(order, 'delivery_instructions') else None,
|
||||
delivery_contact=order.delivery_contact if hasattr(order, 'delivery_contact') else None,
|
||||
delivery_phone=order.delivery_phone if hasattr(order, 'delivery_phone') else None,
|
||||
requires_approval=order.requires_approval if hasattr(order, 'requires_approval') else False,
|
||||
approved_by=order.approved_by if hasattr(order, 'approved_by') else None,
|
||||
approved_at=adjusted_approved_at,
|
||||
rejection_reason=order.rejection_reason if hasattr(order, 'rejection_reason') else None,
|
||||
auto_approved=order.auto_approved if hasattr(order, 'auto_approved') else False,
|
||||
auto_approval_rule_id=order.auto_approval_rule_id if hasattr(order, 'auto_approval_rule_id') else None,
|
||||
sent_to_supplier_at=adjusted_sent_to_supplier_at,
|
||||
supplier_confirmation_date=adjusted_supplier_confirmation,
|
||||
supplier_reference=order.supplier_reference if hasattr(order, 'supplier_reference') else None,
|
||||
notes=order.notes if hasattr(order, 'notes') else None,
|
||||
internal_notes=order.internal_notes if hasattr(order, 'internal_notes') else None,
|
||||
terms_and_conditions=order.terms_and_conditions if hasattr(order, 'terms_and_conditions') else None,
|
||||
created_at=session_time,
|
||||
updated_at=session_time,
|
||||
created_by=system_user_id,
|
||||
updated_by=system_user_id
|
||||
)
|
||||
db.add(new_order)
|
||||
stats["purchase_orders"] += 1
|
||||
|
||||
# Clone Purchase Order Items
|
||||
for old_order_id, new_order_id in order_id_map.items():
|
||||
result = await db.execute(
|
||||
select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == old_order_id)
|
||||
)
|
||||
order_items = result.scalars().all()
|
||||
|
||||
for item in order_items:
|
||||
new_item = PurchaseOrderItem(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=virtual_uuid,
|
||||
purchase_order_id=new_order_id,
|
||||
procurement_requirement_id=item.procurement_requirement_id if hasattr(item, 'procurement_requirement_id') else None,
|
||||
inventory_product_id=item.inventory_product_id,
|
||||
product_code=item.product_code if hasattr(item, 'product_code') else None,
|
||||
product_name=item.product_name,
|
||||
supplier_price_list_id=item.supplier_price_list_id if hasattr(item, 'supplier_price_list_id') else None,
|
||||
ordered_quantity=item.ordered_quantity,
|
||||
unit_of_measure=item.unit_of_measure,
|
||||
unit_price=item.unit_price,
|
||||
line_total=item.line_total,
|
||||
received_quantity=item.received_quantity if hasattr(item, 'received_quantity') else 0,
|
||||
remaining_quantity=item.remaining_quantity if hasattr(item, 'remaining_quantity') else item.ordered_quantity,
|
||||
quality_requirements=item.quality_requirements if hasattr(item, 'quality_requirements') else None,
|
||||
item_notes=item.item_notes if hasattr(item, 'item_notes') else None,
|
||||
created_at=session_time,
|
||||
updated_at=session_time
|
||||
)
|
||||
db.add(new_item)
|
||||
stats["purchase_order_items"] += 1
|
||||
|
||||
# Clone Replenishment Plans with Items
|
||||
result = await db.execute(
|
||||
select(ReplenishmentPlan).where(ReplenishmentPlan.tenant_id == base_uuid)
|
||||
)
|
||||
base_replenishment_plans = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Found replenishment plans to clone",
|
||||
count=len(base_replenishment_plans),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
|
||||
replan_id_map = {}
|
||||
|
||||
for replan in base_replenishment_plans:
|
||||
new_replan_id = uuid.uuid4()
|
||||
replan_id_map[replan.id] = new_replan_id
|
||||
|
||||
new_replan = ReplenishmentPlan(
|
||||
id=new_replan_id,
|
||||
tenant_id=virtual_uuid,
|
||||
plan_number=f"REPL-{uuid.uuid4().hex[:8].upper()}",
|
||||
plan_date=replan.plan_date + plan_date_offset if replan.plan_date else None,
|
||||
plan_period_start=replan.plan_period_start + plan_date_offset if replan.plan_period_start else None,
|
||||
plan_period_end=replan.plan_period_end + plan_date_offset if replan.plan_period_end else None,
|
||||
planning_horizon_days=replan.planning_horizon_days,
|
||||
status=replan.status,
|
||||
plan_type=replan.plan_type,
|
||||
priority=replan.priority,
|
||||
business_model=replan.business_model,
|
||||
total_items=replan.total_items,
|
||||
total_estimated_cost=replan.total_estimated_cost,
|
||||
created_at=session_time,
|
||||
updated_at=session_time
|
||||
)
|
||||
db.add(new_replan)
|
||||
stats["replenishment_plans"] += 1
|
||||
|
||||
# Clone Replenishment Plan Items
|
||||
for old_replan_id, new_replan_id in replan_id_map.items():
|
||||
result = await db.execute(
|
||||
select(ReplenishmentPlanItem).where(ReplenishmentPlanItem.plan_id == old_replan_id)
|
||||
)
|
||||
replan_items = result.scalars().all()
|
||||
|
||||
for item in replan_items:
|
||||
new_item = ReplenishmentPlanItem(
|
||||
id=uuid.uuid4(),
|
||||
plan_id=new_replan_id,
|
||||
product_id=item.product_id,
|
||||
product_name=item.product_name,
|
||||
product_sku=item.product_sku,
|
||||
required_quantity=item.required_quantity,
|
||||
unit_of_measure=item.unit_of_measure,
|
||||
current_stock_level=item.current_stock_level,
|
||||
safety_stock_quantity=item.safety_stock_quantity,
|
||||
suggested_order_quantity=item.suggested_order_quantity,
|
||||
supplier_id=item.supplier_id,
|
||||
supplier_name=item.supplier_name,
|
||||
estimated_delivery_days=item.estimated_delivery_days,
|
||||
required_by_date=item.required_by_date + plan_date_offset if item.required_by_date else None,
|
||||
status=item.status,
|
||||
priority=item.priority,
|
||||
notes=item.notes,
|
||||
created_at=session_time,
|
||||
updated_at=session_time
|
||||
)
|
||||
db.add(new_item)
|
||||
stats["replenishment_items"] += 1
|
||||
|
||||
# Commit cloned data
|
||||
await db.commit()
|
||||
|
||||
total_records = sum(stats.values())
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Procurement data cloning completed",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
total_records=total_records,
|
||||
stats=stats,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "procurement",
|
||||
"status": "completed",
|
||||
"records_cloned": total_records,
|
||||
"duration_ms": duration_ms,
|
||||
"details": stats
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e))
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to clone procurement data",
|
||||
error=str(e),
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Rollback on error
|
||||
await db.rollback()
|
||||
|
||||
return {
|
||||
"service": "procurement",
|
||||
"status": "failed",
|
||||
"records_cloned": 0,
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clone/health")
|
||||
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
|
||||
"""
|
||||
Health check for internal cloning endpoint
|
||||
Used by orchestrator to verify service availability
|
||||
"""
|
||||
return {
|
||||
"service": "procurement",
|
||||
"clone_endpoint": "available",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_data(
|
||||
virtual_tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""Delete all procurement data for a virtual demo tenant"""
|
||||
logger.info("Deleting procurement data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
|
||||
# Count records
|
||||
po_count = await db.scalar(select(func.count(PurchaseOrder.id)).where(PurchaseOrder.tenant_id == virtual_uuid))
|
||||
item_count = await db.scalar(select(func.count(PurchaseOrderItem.id)).where(PurchaseOrderItem.tenant_id == virtual_uuid))
|
||||
plan_count = await db.scalar(select(func.count(ProcurementPlan.id)).where(ProcurementPlan.tenant_id == virtual_uuid))
|
||||
req_count = await db.scalar(select(func.count(ProcurementRequirement.id)).where(ProcurementRequirement.tenant_id == virtual_uuid))
|
||||
replan_count = await db.scalar(select(func.count(ReplenishmentPlan.id)).where(ReplenishmentPlan.tenant_id == virtual_uuid))
|
||||
replan_item_count = await db.scalar(select(func.count(ReplenishmentPlanItem.id)).where(ReplenishmentPlanItem.tenant_id == virtual_uuid))
|
||||
|
||||
# Delete in order (respecting foreign key constraints)
|
||||
await db.execute(delete(PurchaseOrderItem).where(PurchaseOrderItem.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(PurchaseOrder).where(PurchaseOrder.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(ProcurementRequirement).where(ProcurementRequirement.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(ProcurementPlan).where(ProcurementPlan.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(ReplenishmentPlanItem).where(ReplenishmentPlanItem.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(ReplenishmentPlan).where(ReplenishmentPlan.tenant_id == virtual_uuid))
|
||||
await db.commit()
|
||||
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
logger.info("Procurement data deleted successfully", virtual_tenant_id=virtual_tenant_id, duration_ms=duration_ms)
|
||||
|
||||
return {
|
||||
"service": "procurement",
|
||||
"status": "deleted",
|
||||
"virtual_tenant_id": virtual_tenant_id,
|
||||
"records_deleted": {
|
||||
"purchase_orders": po_count,
|
||||
"purchase_order_items": item_count,
|
||||
"procurement_plans": plan_count,
|
||||
"procurement_requirements": req_count,
|
||||
"replenishment_plans": replan_count,
|
||||
"replenishment_items": replan_item_count,
|
||||
"total": po_count + item_count + plan_count + req_count + replan_count + replan_item_count
|
||||
},
|
||||
"duration_ms": duration_ms
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete procurement data", error=str(e), exc_info=True)
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
319
services/procurement/app/api/procurement_plans.py
Normal file
319
services/procurement/app/api/procurement_plans.py
Normal file
@@ -0,0 +1,319 @@
|
||||
# ================================================================
|
||||
# services/procurement/app/api/procurement_plans.py
|
||||
# ================================================================
|
||||
"""
|
||||
Procurement Plans API - Endpoints for procurement planning
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.services.procurement_service import ProcurementService
|
||||
from app.schemas.procurement_schemas import (
|
||||
ProcurementPlanResponse,
|
||||
GeneratePlanRequest,
|
||||
GeneratePlanResponse,
|
||||
AutoGenerateProcurementRequest,
|
||||
AutoGenerateProcurementResponse,
|
||||
PaginatedProcurementPlans,
|
||||
)
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/procurement", tags=["Procurement Plans"])
|
||||
|
||||
|
||||
def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
|
||||
"""Dependency to get procurement service"""
|
||||
return ProcurementService(db, settings)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# ORCHESTRATOR ENTRY POINT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/auto-generate", response_model=AutoGenerateProcurementResponse)
|
||||
async def auto_generate_procurement(
|
||||
tenant_id: str,
|
||||
request_data: AutoGenerateProcurementRequest,
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Auto-generate procurement plan from forecast data (called by Orchestrator)
|
||||
|
||||
This is the main entry point for orchestrated procurement planning.
|
||||
The Orchestrator calls Forecasting Service first, then passes forecast data here.
|
||||
|
||||
Flow:
|
||||
1. Receive forecast data from orchestrator
|
||||
2. Calculate procurement requirements
|
||||
3. Apply Recipe Explosion for locally-produced items
|
||||
4. Create procurement plan
|
||||
5. Optionally create and auto-approve purchase orders
|
||||
|
||||
Returns:
|
||||
AutoGenerateProcurementResponse with plan details and created POs
|
||||
"""
|
||||
try:
|
||||
logger.info("Auto-generate procurement endpoint called",
|
||||
tenant_id=tenant_id,
|
||||
has_forecast_data=bool(request_data.forecast_data))
|
||||
|
||||
result = await service.auto_generate_procurement(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
request=request_data
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error in auto_generate_procurement endpoint", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ================================================================
|
||||
# MANUAL PROCUREMENT PLAN GENERATION
|
||||
# ================================================================
|
||||
|
||||
@router.post("/plans/generate", response_model=GeneratePlanResponse)
|
||||
async def generate_procurement_plan(
|
||||
tenant_id: str,
|
||||
request_data: GeneratePlanRequest,
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""
|
||||
Generate a new procurement plan (manual/UI-driven)
|
||||
|
||||
This endpoint is used for manual procurement planning from the UI.
|
||||
Unlike auto_generate_procurement, this generates its own forecasts.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
request_data: Plan generation parameters
|
||||
|
||||
Returns:
|
||||
GeneratePlanResponse with the created plan
|
||||
"""
|
||||
try:
|
||||
logger.info("Generate procurement plan endpoint called",
|
||||
tenant_id=tenant_id,
|
||||
plan_date=request_data.plan_date)
|
||||
|
||||
result = await service.generate_procurement_plan(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
request=request_data
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating procurement plan", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ================================================================
|
||||
# PROCUREMENT PLAN CRUD
|
||||
# ================================================================
|
||||
|
||||
@router.get("/plans/current", response_model=Optional[ProcurementPlanResponse])
|
||||
async def get_current_plan(
|
||||
tenant_id: str,
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get the current day's procurement plan"""
|
||||
try:
|
||||
plan = await service.get_current_plan(uuid.UUID(tenant_id))
|
||||
return plan
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting current plan", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}", response_model=ProcurementPlanResponse)
|
||||
async def get_plan_by_id(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement plan by ID"""
|
||||
try:
|
||||
plan = await service.get_plan_by_id(uuid.UUID(tenant_id), uuid.UUID(plan_id))
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
return plan
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting plan by ID", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans/date/{plan_date}", response_model=Optional[ProcurementPlanResponse])
|
||||
async def get_plan_by_date(
|
||||
tenant_id: str,
|
||||
plan_date: date,
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement plan for a specific date"""
|
||||
try:
|
||||
plan = await service.get_plan_by_date(uuid.UUID(tenant_id), plan_date)
|
||||
return plan
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting plan by date", error=str(e), tenant_id=tenant_id, plan_date=plan_date)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans", response_model=PaginatedProcurementPlans)
|
||||
async def list_procurement_plans(
|
||||
tenant_id: str,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List all procurement plans for tenant with pagination"""
|
||||
try:
|
||||
from app.repositories.procurement_plan_repository import ProcurementPlanRepository
|
||||
|
||||
repo = ProcurementPlanRepository(db)
|
||||
plans = await repo.list_plans(uuid.UUID(tenant_id), skip=skip, limit=limit)
|
||||
total = await repo.count_plans(uuid.UUID(tenant_id))
|
||||
|
||||
plans_response = [ProcurementPlanResponse.model_validate(p) for p in plans]
|
||||
|
||||
return PaginatedProcurementPlans(
|
||||
plans=plans_response,
|
||||
total=total,
|
||||
page=skip // limit + 1,
|
||||
limit=limit,
|
||||
has_more=(skip + limit) < total
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error listing procurement plans", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/plans/{plan_id}/status")
|
||||
async def update_plan_status(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
|
||||
notes: Optional[str] = None,
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Update procurement plan status"""
|
||||
try:
|
||||
updated_plan = await service.update_plan_status(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
plan_id=uuid.UUID(plan_id),
|
||||
status=status,
|
||||
approval_notes=notes
|
||||
)
|
||||
|
||||
if not updated_plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
return updated_plan
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating plan status", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/create-purchase-orders")
|
||||
async def create_purchase_orders_from_plan(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""
|
||||
Create purchase orders from procurement plan requirements
|
||||
|
||||
Groups requirements by supplier and creates POs automatically.
|
||||
Optionally evaluates auto-approval rules for qualifying POs.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
plan_id: Procurement plan UUID
|
||||
auto_approve: Whether to auto-approve qualifying POs
|
||||
|
||||
Returns:
|
||||
Summary of created, approved, and failed purchase orders
|
||||
"""
|
||||
try:
|
||||
result = await service.create_purchase_orders_from_plan(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
plan_id=uuid.UUID(plan_id),
|
||||
auto_approve=auto_approve
|
||||
)
|
||||
|
||||
if not result.get('success'):
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create purchase orders'))
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error creating POs from plan", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ================================================================
|
||||
# TESTING AND UTILITIES
|
||||
# ================================================================
|
||||
|
||||
@router.get("/plans/{plan_id}/requirements")
|
||||
async def get_plan_requirements(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all requirements for a procurement plan"""
|
||||
try:
|
||||
from app.repositories.procurement_plan_repository import ProcurementRequirementRepository
|
||||
|
||||
repo = ProcurementRequirementRepository(db)
|
||||
requirements = await repo.get_requirements_by_plan(uuid.UUID(plan_id))
|
||||
|
||||
return {
|
||||
"plan_id": plan_id,
|
||||
"requirements_count": len(requirements),
|
||||
"requirements": [
|
||||
{
|
||||
"id": str(req.id),
|
||||
"requirement_number": req.requirement_number,
|
||||
"product_name": req.product_name,
|
||||
"net_requirement": float(req.net_requirement),
|
||||
"unit_of_measure": req.unit_of_measure,
|
||||
"priority": req.priority,
|
||||
"status": req.status,
|
||||
"is_locally_produced": req.is_locally_produced,
|
||||
"bom_explosion_level": req.bom_explosion_level,
|
||||
"supplier_name": req.supplier_name,
|
||||
"estimated_total_cost": float(req.estimated_total_cost or 0)
|
||||
}
|
||||
for req in requirements
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting plan requirements", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
458
services/procurement/app/api/purchase_orders.py
Normal file
458
services/procurement/app/api/purchase_orders.py
Normal file
@@ -0,0 +1,458 @@
|
||||
# ================================================================
|
||||
# services/procurement/app/api/purchase_orders.py
|
||||
# ================================================================
|
||||
"""
|
||||
Purchase Orders API - Endpoints for purchase order management
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.services.purchase_order_service import PurchaseOrderService
|
||||
from app.schemas.purchase_order_schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderUpdate,
|
||||
PurchaseOrderResponse,
|
||||
PurchaseOrderApproval,
|
||||
DeliveryCreate,
|
||||
DeliveryResponse,
|
||||
SupplierInvoiceCreate,
|
||||
SupplierInvoiceResponse,
|
||||
)
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/purchase-orders", tags=["Purchase Orders"])
|
||||
|
||||
|
||||
def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService:
|
||||
"""Dependency to get purchase order service"""
|
||||
return PurchaseOrderService(db, settings)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# PURCHASE ORDER CRUD
|
||||
# ================================================================
|
||||
|
||||
@router.post("", response_model=PurchaseOrderResponse, status_code=201)
|
||||
async def create_purchase_order(
|
||||
tenant_id: str,
|
||||
po_data: PurchaseOrderCreate,
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Create a new purchase order with items
|
||||
|
||||
Creates a PO with automatic approval rules evaluation.
|
||||
Links to procurement plan if procurement_plan_id is provided.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
po_data: Purchase order creation data
|
||||
|
||||
Returns:
|
||||
PurchaseOrderResponse with created PO details
|
||||
"""
|
||||
try:
|
||||
logger.info("Create PO endpoint called", tenant_id=tenant_id)
|
||||
|
||||
po = await service.create_purchase_order(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
po_data=po_data
|
||||
)
|
||||
|
||||
return PurchaseOrderResponse.model_validate(po)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error creating purchase order", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{po_id}", response_model=PurchaseOrderResponse)
|
||||
async def get_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""Get purchase order by ID with items"""
|
||||
try:
|
||||
po = await service.get_purchase_order(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
po_id=uuid.UUID(po_id)
|
||||
)
|
||||
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.model_validate(po)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting purchase order", error=str(e), po_id=po_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[PurchaseOrderResponse])
|
||||
async def list_purchase_orders(
|
||||
tenant_id: str,
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
supplier_id: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
List purchase orders with filters
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
skip: Number of records to skip (pagination)
|
||||
limit: Maximum number of records to return
|
||||
supplier_id: Filter by supplier ID (optional)
|
||||
status: Filter by status (optional)
|
||||
|
||||
Returns:
|
||||
List of purchase orders
|
||||
"""
|
||||
try:
|
||||
pos = await service.list_purchase_orders(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
supplier_id=uuid.UUID(supplier_id) if supplier_id else None,
|
||||
status=status
|
||||
)
|
||||
|
||||
return [PurchaseOrderResponse.model_validate(po) for po in pos]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{po_id}", response_model=PurchaseOrderResponse)
|
||||
async def update_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
po_data: PurchaseOrderUpdate,
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Update purchase order information
|
||||
|
||||
Only draft or pending_approval orders can be modified.
|
||||
Financial field changes trigger automatic total recalculation.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
po_id: Purchase order UUID
|
||||
po_data: Update data
|
||||
|
||||
Returns:
|
||||
Updated purchase order
|
||||
"""
|
||||
try:
|
||||
po = await service.update_purchase_order(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
po_id=uuid.UUID(po_id),
|
||||
po_data=po_data
|
||||
)
|
||||
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.model_validate(po)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating purchase order", error=str(e), po_id=po_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{po_id}/status")
|
||||
async def update_order_status(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
status: str = Query(..., description="New status"),
|
||||
notes: Optional[str] = Query(default=None),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Update purchase order status
|
||||
|
||||
Validates status transitions to prevent invalid state changes.
|
||||
|
||||
Valid transitions:
|
||||
- draft -> pending_approval, approved, cancelled
|
||||
- pending_approval -> approved, rejected, cancelled
|
||||
- approved -> sent_to_supplier, cancelled
|
||||
- sent_to_supplier -> confirmed, cancelled
|
||||
- confirmed -> in_production, cancelled
|
||||
- in_production -> shipped, cancelled
|
||||
- shipped -> delivered, cancelled
|
||||
- delivered -> completed
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
po_id: Purchase order UUID
|
||||
status: New status
|
||||
notes: Optional status change notes
|
||||
|
||||
Returns:
|
||||
Updated purchase order
|
||||
"""
|
||||
try:
|
||||
po = await service.update_order_status(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
po_id=uuid.UUID(po_id),
|
||||
status=status,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.model_validate(po)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating PO status", error=str(e), po_id=po_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ================================================================
|
||||
# APPROVAL WORKFLOW
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/approve", response_model=PurchaseOrderResponse)
|
||||
async def approve_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
approval_data: PurchaseOrderApproval,
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Approve or reject a purchase order
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
po_id: Purchase order UUID
|
||||
approval_data: Approval or rejection data
|
||||
|
||||
Returns:
|
||||
Updated purchase order
|
||||
"""
|
||||
try:
|
||||
if approval_data.action == "approve":
|
||||
po = await service.approve_purchase_order(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
po_id=uuid.UUID(po_id),
|
||||
approved_by=approval_data.approved_by,
|
||||
approval_notes=approval_data.notes
|
||||
)
|
||||
elif approval_data.action == "reject":
|
||||
po = await service.reject_purchase_order(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
po_id=uuid.UUID(po_id),
|
||||
rejected_by=approval_data.approved_by,
|
||||
rejection_reason=approval_data.notes or "No reason provided"
|
||||
)
|
||||
else:
|
||||
raise ValueError("Invalid action. Must be 'approve' or 'reject'")
|
||||
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.model_validate(po)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error in PO approval workflow", error=str(e), po_id=po_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{po_id}/cancel", response_model=PurchaseOrderResponse)
|
||||
async def cancel_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
reason: str = Query(..., description="Cancellation reason"),
|
||||
cancelled_by: Optional[str] = Query(default=None),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Cancel a purchase order
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
po_id: Purchase order UUID
|
||||
reason: Cancellation reason
|
||||
cancelled_by: User ID performing cancellation
|
||||
|
||||
Returns:
|
||||
Cancelled purchase order
|
||||
"""
|
||||
try:
|
||||
po = await service.cancel_purchase_order(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
po_id=uuid.UUID(po_id),
|
||||
cancelled_by=uuid.UUID(cancelled_by) if cancelled_by else None,
|
||||
cancellation_reason=reason
|
||||
)
|
||||
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
return PurchaseOrderResponse.model_validate(po)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error cancelling purchase order", error=str(e), po_id=po_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ================================================================
|
||||
# DELIVERY MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/deliveries", response_model=DeliveryResponse, status_code=201)
|
||||
async def create_delivery(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
delivery_data: DeliveryCreate,
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Create a delivery record for a purchase order
|
||||
|
||||
Tracks delivery scheduling, items, quality inspection, and receipt.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
po_id: Purchase order UUID
|
||||
delivery_data: Delivery creation data
|
||||
|
||||
Returns:
|
||||
DeliveryResponse with created delivery details
|
||||
"""
|
||||
try:
|
||||
# Validate PO ID matches
|
||||
if str(delivery_data.purchase_order_id) != po_id:
|
||||
raise ValueError("Purchase order ID mismatch")
|
||||
|
||||
delivery = await service.create_delivery(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
delivery_data=delivery_data,
|
||||
created_by=uuid.uuid4() # TODO: Get from auth context
|
||||
)
|
||||
|
||||
return DeliveryResponse.model_validate(delivery)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error creating delivery", error=str(e), po_id=po_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/deliveries/{delivery_id}/status")
|
||||
async def update_delivery_status(
|
||||
tenant_id: str,
|
||||
delivery_id: str,
|
||||
status: str = Query(..., description="New delivery status"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Update delivery status
|
||||
|
||||
Valid statuses: scheduled, in_transit, delivered, completed, cancelled
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
delivery_id: Delivery UUID
|
||||
status: New status
|
||||
|
||||
Returns:
|
||||
Updated delivery
|
||||
"""
|
||||
try:
|
||||
delivery = await service.update_delivery_status(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
delivery_id=uuid.UUID(delivery_id),
|
||||
status=status,
|
||||
updated_by=uuid.uuid4() # TODO: Get from auth context
|
||||
)
|
||||
|
||||
if not delivery:
|
||||
raise HTTPException(status_code=404, detail="Delivery not found")
|
||||
|
||||
return DeliveryResponse.model_validate(delivery)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating delivery status", error=str(e), delivery_id=delivery_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ================================================================
|
||||
# INVOICE MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/invoices", response_model=SupplierInvoiceResponse, status_code=201)
|
||||
async def create_invoice(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
invoice_data: SupplierInvoiceCreate,
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
Create a supplier invoice for a purchase order
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
po_id: Purchase order UUID
|
||||
invoice_data: Invoice creation data
|
||||
|
||||
Returns:
|
||||
SupplierInvoiceResponse with created invoice details
|
||||
"""
|
||||
try:
|
||||
# Validate PO ID matches
|
||||
if str(invoice_data.purchase_order_id) != po_id:
|
||||
raise ValueError("Purchase order ID mismatch")
|
||||
|
||||
invoice = await service.create_invoice(
|
||||
tenant_id=uuid.UUID(tenant_id),
|
||||
invoice_data=invoice_data,
|
||||
created_by=uuid.uuid4() # TODO: Get from auth context
|
||||
)
|
||||
|
||||
return SupplierInvoiceResponse.model_validate(invoice)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Error creating invoice", error=str(e), po_id=po_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
430
services/procurement/app/api/replenishment.py
Normal file
430
services/procurement/app/api/replenishment.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Replenishment Planning API Routes
|
||||
|
||||
Provides endpoints for advanced replenishment planning including:
|
||||
- Generate replenishment plans
|
||||
- View inventory projections
|
||||
- Review supplier allocations
|
||||
- Get planning analytics
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from datetime import date
|
||||
|
||||
from app.schemas.replenishment import (
|
||||
GenerateReplenishmentPlanRequest,
|
||||
GenerateReplenishmentPlanResponse,
|
||||
ReplenishmentPlanResponse,
|
||||
ReplenishmentPlanSummary,
|
||||
InventoryProjectionResponse,
|
||||
SupplierAllocationResponse,
|
||||
SupplierSelectionRequest,
|
||||
SupplierSelectionResult,
|
||||
SafetyStockRequest,
|
||||
SafetyStockResponse,
|
||||
ProjectInventoryRequest,
|
||||
ProjectInventoryResponse,
|
||||
ReplenishmentAnalytics,
|
||||
MOQAggregationRequest,
|
||||
MOQAggregationResponse
|
||||
)
|
||||
from app.services.procurement_service import ProcurementService
|
||||
from app.services.replenishment_planning_service import ReplenishmentPlanningService
|
||||
from app.services.safety_stock_calculator import SafetyStockCalculator
|
||||
from app.services.inventory_projector import InventoryProjector, DailyDemand, ScheduledReceipt
|
||||
from app.services.moq_aggregator import MOQAggregator
|
||||
from app.services.supplier_selector import SupplierSelector
|
||||
from app.core.dependencies import get_db, get_current_tenant_id
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/replenishment-plans", tags=["Replenishment Planning"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Replenishment Plan Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/generate", response_model=GenerateReplenishmentPlanResponse)
|
||||
async def generate_replenishment_plan(
|
||||
request: GenerateReplenishmentPlanRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generate advanced replenishment plan with:
|
||||
- Lead-time-aware order date calculation
|
||||
- Dynamic safety stock
|
||||
- Inventory projection
|
||||
- Shelf-life management
|
||||
"""
|
||||
try:
|
||||
logger.info("Generating replenishment plan", tenant_id=tenant_id)
|
||||
|
||||
# Initialize replenishment planner
|
||||
planner = ReplenishmentPlanningService(
|
||||
projection_horizon_days=request.projection_horizon_days,
|
||||
default_service_level=request.service_level,
|
||||
default_buffer_days=request.buffer_days
|
||||
)
|
||||
|
||||
# Generate plan
|
||||
plan = await planner.generate_replenishment_plan(
|
||||
tenant_id=str(tenant_id),
|
||||
requirements=request.requirements,
|
||||
forecast_id=request.forecast_id,
|
||||
production_schedule_id=request.production_schedule_id
|
||||
)
|
||||
|
||||
# Export to response
|
||||
plan_dict = planner.export_plan_to_dict(plan)
|
||||
|
||||
return GenerateReplenishmentPlanResponse(**plan_dict)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate replenishment plan",
|
||||
tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[ReplenishmentPlanSummary])
|
||||
async def list_replenishment_plans(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
status: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List replenishment plans for tenant
|
||||
"""
|
||||
try:
|
||||
# Query from database (implementation depends on your repo)
|
||||
# This is a placeholder - implement based on your repository
|
||||
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
|
||||
|
||||
repo = ReplenishmentPlanRepository(db)
|
||||
plans = await repo.list_plans(
|
||||
tenant_id=tenant_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status
|
||||
)
|
||||
|
||||
return plans
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list replenishment plans",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=ReplenishmentPlanResponse)
|
||||
async def get_replenishment_plan(
|
||||
plan_id: UUID = Path(...),
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get replenishment plan by ID
|
||||
"""
|
||||
try:
|
||||
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
|
||||
|
||||
repo = ReplenishmentPlanRepository(db)
|
||||
plan = await repo.get_plan_by_id(plan_id, tenant_id)
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Replenishment plan not found")
|
||||
|
||||
return plan
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get replenishment plan",
|
||||
tenant_id=tenant_id, plan_id=plan_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Inventory Projection Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/inventory-projections/project", response_model=ProjectInventoryResponse)
|
||||
async def project_inventory(
|
||||
request: ProjectInventoryRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
):
|
||||
"""
|
||||
Project inventory levels to identify future stockouts
|
||||
"""
|
||||
try:
|
||||
logger.info("Projecting inventory", tenant_id=tenant_id,
|
||||
ingredient_id=request.ingredient_id)
|
||||
|
||||
projector = InventoryProjector(request.projection_horizon_days)
|
||||
|
||||
# Build daily demand objects
|
||||
daily_demand = [
|
||||
DailyDemand(
|
||||
ingredient_id=request.ingredient_id,
|
||||
date=d['date'],
|
||||
quantity=d['quantity']
|
||||
)
|
||||
for d in request.daily_demand
|
||||
]
|
||||
|
||||
# Build scheduled receipts
|
||||
scheduled_receipts = [
|
||||
ScheduledReceipt(
|
||||
ingredient_id=request.ingredient_id,
|
||||
date=r['date'],
|
||||
quantity=r['quantity'],
|
||||
source=r.get('source', 'purchase_order'),
|
||||
reference_id=r.get('reference_id')
|
||||
)
|
||||
for r in request.scheduled_receipts
|
||||
]
|
||||
|
||||
# Project inventory
|
||||
projection = projector.project_inventory(
|
||||
ingredient_id=request.ingredient_id,
|
||||
ingredient_name=request.ingredient_name,
|
||||
current_stock=request.current_stock,
|
||||
unit_of_measure=request.unit_of_measure,
|
||||
daily_demand=daily_demand,
|
||||
scheduled_receipts=scheduled_receipts
|
||||
)
|
||||
|
||||
# Export to response
|
||||
projection_dict = projector.export_projection_to_dict(projection)
|
||||
|
||||
return ProjectInventoryResponse(**projection_dict)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to project inventory",
|
||||
tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/inventory-projections", response_model=List[InventoryProjectionResponse])
|
||||
async def list_inventory_projections(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
ingredient_id: Optional[UUID] = None,
|
||||
projection_date: Optional[date] = None,
|
||||
stockout_only: bool = False,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List inventory projections
|
||||
"""
|
||||
try:
|
||||
from app.repositories.replenishment_repository import InventoryProjectionRepository
|
||||
|
||||
repo = InventoryProjectionRepository(db)
|
||||
projections = await repo.list_projections(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
projection_date=projection_date,
|
||||
stockout_only=stockout_only,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return projections
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list inventory projections",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Safety Stock Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/safety-stock/calculate", response_model=SafetyStockResponse)
|
||||
async def calculate_safety_stock(
|
||||
request: SafetyStockRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
):
|
||||
"""
|
||||
Calculate dynamic safety stock using statistical methods
|
||||
"""
|
||||
try:
|
||||
logger.info("Calculating safety stock", tenant_id=tenant_id,
|
||||
ingredient_id=request.ingredient_id)
|
||||
|
||||
calculator = SafetyStockCalculator(request.service_level)
|
||||
|
||||
result = calculator.calculate_from_demand_history(
|
||||
daily_demands=request.daily_demands,
|
||||
lead_time_days=request.lead_time_days,
|
||||
service_level=request.service_level
|
||||
)
|
||||
|
||||
return SafetyStockResponse(**calculator.export_to_dict(result))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to calculate safety stock",
|
||||
tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Supplier Selection Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/supplier-selections/evaluate", response_model=SupplierSelectionResult)
|
||||
async def evaluate_supplier_selection(
|
||||
request: SupplierSelectionRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
):
|
||||
"""
|
||||
Evaluate supplier options using multi-criteria decision analysis
|
||||
"""
|
||||
try:
|
||||
logger.info("Evaluating supplier selection", tenant_id=tenant_id,
|
||||
ingredient_id=request.ingredient_id)
|
||||
|
||||
selector = SupplierSelector()
|
||||
|
||||
# Convert supplier options
|
||||
from app.services.supplier_selector import SupplierOption
|
||||
supplier_options = [
|
||||
SupplierOption(**opt) for opt in request.supplier_options
|
||||
]
|
||||
|
||||
result = selector.select_suppliers(
|
||||
ingredient_id=request.ingredient_id,
|
||||
ingredient_name=request.ingredient_name,
|
||||
required_quantity=request.required_quantity,
|
||||
supplier_options=supplier_options
|
||||
)
|
||||
|
||||
return SupplierSelectionResult(**selector.export_result_to_dict(result))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to evaluate supplier selection",
|
||||
tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/supplier-allocations", response_model=List[SupplierAllocationResponse])
|
||||
async def list_supplier_allocations(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
requirement_id: Optional[UUID] = None,
|
||||
supplier_id: Optional[UUID] = None,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List supplier allocations
|
||||
"""
|
||||
try:
|
||||
from app.repositories.replenishment_repository import SupplierAllocationRepository
|
||||
|
||||
repo = SupplierAllocationRepository(db)
|
||||
allocations = await repo.list_allocations(
|
||||
tenant_id=tenant_id,
|
||||
requirement_id=requirement_id,
|
||||
supplier_id=supplier_id,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return allocations
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list supplier allocations",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MOQ Aggregation Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/moq-aggregation/aggregate", response_model=MOQAggregationResponse)
|
||||
async def aggregate_for_moq(
|
||||
request: MOQAggregationRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
):
|
||||
"""
|
||||
Aggregate requirements to meet Minimum Order Quantities
|
||||
"""
|
||||
try:
|
||||
logger.info("Aggregating requirements for MOQ", tenant_id=tenant_id)
|
||||
|
||||
aggregator = MOQAggregator()
|
||||
|
||||
# Convert requirements and constraints
|
||||
from app.services.moq_aggregator import (
|
||||
ProcurementRequirement as MOQReq,
|
||||
SupplierConstraints
|
||||
)
|
||||
|
||||
requirements = [MOQReq(**req) for req in request.requirements]
|
||||
constraints = {
|
||||
k: SupplierConstraints(**v)
|
||||
for k, v in request.supplier_constraints.items()
|
||||
}
|
||||
|
||||
# Aggregate
|
||||
aggregated_orders = aggregator.aggregate_requirements(
|
||||
requirements=requirements,
|
||||
supplier_constraints=constraints
|
||||
)
|
||||
|
||||
# Calculate efficiency
|
||||
efficiency = aggregator.calculate_order_efficiency(aggregated_orders)
|
||||
|
||||
return MOQAggregationResponse(
|
||||
aggregated_orders=[aggregator.export_to_dict(order) for order in aggregated_orders],
|
||||
efficiency_metrics=efficiency
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to aggregate for MOQ",
|
||||
tenant_id=tenant_id, error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Analytics Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.get("/analytics", response_model=ReplenishmentAnalytics)
|
||||
async def get_replenishment_analytics(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get replenishment planning analytics
|
||||
"""
|
||||
try:
|
||||
from app.repositories.replenishment_repository import ReplenishmentAnalyticsRepository
|
||||
|
||||
repo = ReplenishmentAnalyticsRepository(db)
|
||||
analytics = await repo.get_analytics(
|
||||
tenant_id=tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
return analytics
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get replenishment analytics",
|
||||
tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
Reference in New Issue
Block a user