Add fixes to procurement logic and fix rel-time connections
This commit is contained in:
@@ -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
|
||||
# ================================================================
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user