Add fixes to procurement logic and fix rel-time connections

This commit is contained in:
Urtzi Alfaro
2025-10-02 13:20:30 +02:00
parent c9d8d1d071
commit 1243c2ca6d
24 changed files with 4984 additions and 348 deletions

View File

@@ -75,10 +75,13 @@ async def get_current_tenant(
async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
"""Get procurement service instance"""
"""Get procurement service instance with all required clients"""
from shared.clients.suppliers_client import SuppliersServiceClient
inventory_client = InventoryServiceClient(service_settings)
forecast_client = ForecastServiceClient(service_settings, "orders-service")
return ProcurementService(db, service_settings, inventory_client, forecast_client)
suppliers_client = SuppliersServiceClient(service_settings)
return ProcurementService(db, service_settings, inventory_client, forecast_client, suppliers_client)
# ================================================================
@@ -406,6 +409,307 @@ async def get_critical_requirements(
)
# ================================================================
# NEW FEATURE ENDPOINTS
# ================================================================
@router.post("/procurement/plans/{plan_id}/recalculate", response_model=GeneratePlanResponse)
@monitor_performance("recalculate_procurement_plan")
async def recalculate_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Recalculate an existing procurement plan (Edge Case #3)
Useful when inventory has changed significantly after plan creation
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
result = await procurement_service.recalculate_plan(tenant_id, plan_id)
if not result.success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.message
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error recalculating procurement plan: {str(e)}"
)
@router.post("/procurement/requirements/{requirement_id}/link-purchase-order")
@monitor_performance("link_requirement_to_po")
async def link_requirement_to_purchase_order(
tenant_id: uuid.UUID,
requirement_id: uuid.UUID,
purchase_order_id: uuid.UUID,
purchase_order_number: str,
ordered_quantity: float,
expected_delivery_date: Optional[date] = None,
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Link a procurement requirement to a purchase order (Bug #4 FIX, Feature #1)
Updates requirement status and tracks PO information
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
from decimal import Decimal
success = await procurement_service.link_requirement_to_purchase_order(
tenant_id=tenant_id,
requirement_id=requirement_id,
purchase_order_id=purchase_order_id,
purchase_order_number=purchase_order_number,
ordered_quantity=Decimal(str(ordered_quantity)),
expected_delivery_date=expected_delivery_date
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Requirement not found or unauthorized"
)
return {
"success": True,
"message": "Requirement linked to purchase order successfully",
"requirement_id": str(requirement_id),
"purchase_order_id": str(purchase_order_id)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error linking requirement to PO: {str(e)}"
)
@router.put("/procurement/requirements/{requirement_id}/delivery-status")
@monitor_performance("update_delivery_status")
async def update_requirement_delivery_status(
tenant_id: uuid.UUID,
requirement_id: uuid.UUID,
delivery_status: str,
received_quantity: Optional[float] = None,
actual_delivery_date: Optional[date] = None,
quality_rating: Optional[float] = None,
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Update delivery status for a requirement (Feature #2)
Tracks received quantities, delivery dates, and quality ratings
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
from decimal import Decimal
success = await procurement_service.update_delivery_status(
tenant_id=tenant_id,
requirement_id=requirement_id,
delivery_status=delivery_status,
received_quantity=Decimal(str(received_quantity)) if received_quantity is not None else None,
actual_delivery_date=actual_delivery_date,
quality_rating=Decimal(str(quality_rating)) if quality_rating is not None else None
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Requirement not found or unauthorized"
)
return {
"success": True,
"message": "Delivery status updated successfully",
"requirement_id": str(requirement_id),
"delivery_status": delivery_status
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating delivery status: {str(e)}"
)
@router.post("/procurement/plans/{plan_id}/approve")
@monitor_performance("approve_procurement_plan")
async def approve_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
approval_notes: Optional[str] = None,
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Approve a procurement plan (Edge Case #7: Enhanced approval workflow)
Includes approval notes and workflow tracking
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
try:
user_id = uuid.UUID(tenant_access.user_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)
result = await procurement_service.update_plan_status(
tenant_id=tenant_id,
plan_id=plan_id,
status="approved",
updated_by=user_id,
approval_notes=approval_notes
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Plan not found"
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error approving plan: {str(e)}"
)
@router.post("/procurement/plans/{plan_id}/reject")
@monitor_performance("reject_procurement_plan")
async def reject_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
rejection_notes: Optional[str] = None,
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Reject a procurement plan (Edge Case #7: Enhanced approval workflow)
Marks plan as cancelled with rejection notes
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
try:
user_id = uuid.UUID(tenant_access.user_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)
result = await procurement_service.update_plan_status(
tenant_id=tenant_id,
plan_id=plan_id,
status="cancelled",
updated_by=user_id,
approval_notes=f"REJECTED: {rejection_notes}" if rejection_notes else "REJECTED"
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Plan not found"
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error rejecting plan: {str(e)}"
)
@router.post("/procurement/plans/{plan_id}/create-purchase-orders")
@monitor_performance("create_pos_from_plan")
async def create_purchase_orders_from_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
auto_approve: bool = False,
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Automatically create purchase orders from procurement plan (Feature #1)
Groups requirements by supplier and creates POs automatically
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
result = await procurement_service.create_purchase_orders_from_plan(
tenant_id=tenant_id,
plan_id=plan_id,
auto_approve=auto_approve
)
if not result.get('success'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.get('error', 'Failed to create purchase orders')
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating purchase orders: {str(e)}"
)
# ================================================================
# UTILITY ENDPOINTS
# ================================================================

View File

@@ -117,7 +117,7 @@ class ProcurementPlanRepository(BaseRepository):
async def generate_plan_number(self, tenant_id: uuid.UUID, plan_date: date) -> str:
"""Generate unique plan number"""
date_str = plan_date.strftime("%Y%m%d")
# Count existing plans for the same date
stmt = select(func.count(ProcurementPlan.id)).where(
and_(
@@ -127,9 +127,28 @@ class ProcurementPlanRepository(BaseRepository):
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"PP-{date_str}-{count + 1:03d}"
async def archive_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> bool:
"""Archive a completed plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
if not plan:
return False
# Add archived flag to metadata if you have a JSONB field
# or just mark as archived in status
if hasattr(plan, 'metadata'):
metadata = plan.metadata or {}
metadata['archived'] = True
metadata['archived_at'] = datetime.utcnow().isoformat()
plan.metadata = metadata
plan.status = 'archived'
plan.updated_at = datetime.utcnow()
await self.db.flush()
return True
class ProcurementRequirementRepository(BaseRepository):
"""Repository for procurement requirement operations"""
@@ -198,20 +217,34 @@ class ProcurementRequirementRepository(BaseRepository):
async def update_requirement(
self,
requirement_id: uuid.UUID,
tenant_id: uuid.UUID,
updates: Dict[str, Any]
) -> Optional[ProcurementRequirement]:
"""Update procurement requirement"""
requirement = await self.get_requirement_by_id(requirement_id, tenant_id)
"""Update procurement requirement (without tenant_id check for internal use)"""
stmt = select(ProcurementRequirement).where(
ProcurementRequirement.id == requirement_id
)
result = await self.db.execute(stmt)
requirement = result.scalar_one_or_none()
if not requirement:
return None
for key, value in updates.items():
if hasattr(requirement, key):
setattr(requirement, key, value)
requirement.updated_at = datetime.utcnow()
await self.db.flush()
return requirement
async def get_by_id(self, requirement_id: uuid.UUID) -> Optional[ProcurementRequirement]:
"""Get requirement by ID with plan preloaded"""
stmt = select(ProcurementRequirement).where(
ProcurementRequirement.id == requirement_id
).options(selectinload(ProcurementRequirement.plan))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_pending_requirements(self, tenant_id: uuid.UUID) -> List[ProcurementRequirement]:
"""Get all pending requirements across plans"""

View File

@@ -50,6 +50,17 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
max_instances=1
)
# Stale plan cleanup at 6:30 AM (Bug #3 FIX, Edge Cases #1 & #2)
self.scheduler.add_job(
func=self.run_stale_plan_cleanup,
trigger=CronTrigger(hour=6, minute=30),
id="stale_plan_cleanup",
name="Stale Plan Cleanup & Reminders",
misfire_grace_time=300,
coalesce=True,
max_instances=1
)
# Also add a test job that runs every 30 minutes for development/testing
# This will be disabled in production via environment variable
if getattr(self.config, 'DEBUG', False) or getattr(self.config, 'PROCUREMENT_TEST_MODE', False):
@@ -79,7 +90,10 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
jobs_count=len(self.scheduler.get_jobs()))
async def run_daily_procurement_planning(self):
"""Execute daily procurement planning for all active tenants"""
"""
Execute daily procurement planning for all active tenants
Edge Case #6: Uses parallel processing with per-tenant timeouts
"""
if not self.is_leader:
logger.debug("Skipping procurement planning - not leader")
return
@@ -95,20 +109,21 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
logger.info("No active tenants found for procurement planning")
return
# Process each tenant
processed_tenants = 0
failed_tenants = 0
for tenant_id in active_tenants:
try:
logger.info("Processing tenant procurement", tenant_id=str(tenant_id))
await self.process_tenant_procurement(tenant_id)
processed_tenants += 1
logger.info("✅ Successfully processed tenant", tenant_id=str(tenant_id))
except Exception as e:
failed_tenants += 1
logger.error("❌ Error processing tenant procurement",
tenant_id=str(tenant_id),
error=str(e))
# Edge Case #6: Process tenants in parallel with individual error handling
logger.info(f"Processing {len(active_tenants)} tenants in parallel")
# Create tasks with timeout for each tenant
tasks = [
self._process_tenant_with_timeout(tenant_id, timeout_seconds=120)
for tenant_id in active_tenants
]
# Execute all tasks in parallel
results = await asyncio.gather(*tasks, return_exceptions=True)
# Count successes and failures
processed_tenants = sum(1 for r in results if r is True)
failed_tenants = sum(1 for r in results if isinstance(r, Exception) or r is False)
logger.info("🎯 Daily procurement planning completed",
total_tenants=len(active_tenants),
@@ -118,6 +133,75 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
except Exception as e:
self._errors_count += 1
logger.error("💥 Daily procurement planning failed completely", error=str(e))
async def _process_tenant_with_timeout(self, tenant_id: UUID, timeout_seconds: int = 120) -> bool:
"""
Process tenant procurement with timeout (Edge Case #6)
Returns True on success, False or raises exception on failure
"""
try:
await asyncio.wait_for(
self.process_tenant_procurement(tenant_id),
timeout=timeout_seconds
)
logger.info("✅ Successfully processed tenant", tenant_id=str(tenant_id))
return True
except asyncio.TimeoutError:
logger.error("⏱️ Tenant processing timed out",
tenant_id=str(tenant_id),
timeout=timeout_seconds)
return False
except Exception as e:
logger.error("❌ Error processing tenant procurement",
tenant_id=str(tenant_id),
error=str(e))
raise
async def run_stale_plan_cleanup(self):
"""
Clean up stale plans, send reminders and escalations
Bug #3 FIX, Edge Cases #1 & #2
"""
if not self.is_leader:
logger.debug("Skipping stale plan cleanup - not leader")
return
try:
logger.info("🧹 Starting stale plan cleanup")
active_tenants = await self.get_active_tenants()
if not active_tenants:
logger.info("No active tenants found for cleanup")
return
total_archived = 0
total_cancelled = 0
total_escalated = 0
# Process each tenant's stale plans
for tenant_id in active_tenants:
try:
async with self.db_session_factory() as session:
procurement_service = ProcurementService(session, self.config)
stats = await procurement_service.cleanup_stale_plans(tenant_id)
total_archived += stats.get('archived', 0)
total_cancelled += stats.get('cancelled', 0)
total_escalated += stats.get('escalated', 0)
except Exception as e:
logger.error("Error cleaning up tenant plans",
tenant_id=str(tenant_id),
error=str(e))
logger.info("✅ Stale plan cleanup completed",
archived=total_archived,
cancelled=total_cancelled,
escalated=total_escalated)
except Exception as e:
self._errors_count += 1
logger.error("💥 Stale plan cleanup failed", error=str(e))
async def get_active_tenants(self) -> List[UUID]:
"""Get active tenants from tenant service or base implementation"""

File diff suppressed because it is too large Load Diff