Improve the frontend
This commit is contained in:
@@ -62,7 +62,7 @@ async def get_orders_service(db = Depends(get_db)) -> OrdersService:
|
||||
# ===== Order CRUD Endpoints =====
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("orders"),
|
||||
route_builder.build_base_route("").rstrip("/"),
|
||||
response_model=OrderResponse,
|
||||
status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -106,7 +106,7 @@ async def create_order(
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("orders"),
|
||||
route_builder.build_base_route("").rstrip("/"),
|
||||
response_model=List[OrderResponse]
|
||||
)
|
||||
async def get_orders(
|
||||
|
||||
@@ -57,6 +57,7 @@ class CustomerSegment(enum.Enum):
|
||||
|
||||
class PriorityLevel(enum.Enum):
|
||||
"""Priority levels for orders and customers"""
|
||||
URGENT = "urgent"
|
||||
HIGH = "high"
|
||||
NORMAL = "normal"
|
||||
LOW = "low"
|
||||
|
||||
200
services/orders/app/services/approval_rules_service.py
Normal file
200
services/orders/app/services/approval_rules_service.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# services/orders/app/services/approval_rules_service.py
|
||||
"""
|
||||
Approval Rules Service - Smart auto-approval logic for purchase orders
|
||||
Evaluates POs against configurable business rules to determine if auto-approval is appropriate
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from shared.config.base import BaseServiceSettings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ApprovalRulesService:
|
||||
"""
|
||||
Service for evaluating purchase orders against approval rules
|
||||
Implements smart auto-approval logic based on multiple criteria
|
||||
"""
|
||||
|
||||
def __init__(self, config: BaseServiceSettings):
|
||||
self.config = config
|
||||
|
||||
async def evaluate_po_for_auto_approval(
|
||||
self,
|
||||
po_data: Dict[str, Any],
|
||||
supplier_data: Optional[Dict[str, Any]] = None,
|
||||
requirements_data: Optional[List[Dict[str, Any]]] = None
|
||||
) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Evaluate if a PO should be auto-approved
|
||||
|
||||
Returns:
|
||||
Tuple of (should_auto_approve, reasons)
|
||||
"""
|
||||
if not self.config.AUTO_APPROVE_ENABLED:
|
||||
return False, ["Auto-approval is disabled in configuration"]
|
||||
|
||||
reasons = []
|
||||
should_approve = True
|
||||
|
||||
# Rule 1: Amount threshold check
|
||||
total_amount = self._calculate_po_total(po_data)
|
||||
if total_amount > self.config.AUTO_APPROVE_THRESHOLD_EUR:
|
||||
should_approve = False
|
||||
reasons.append(
|
||||
f"PO amount €{total_amount:.2f} exceeds threshold €{self.config.AUTO_APPROVE_THRESHOLD_EUR:.2f}"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"PO amount €{total_amount:.2f} within threshold €{self.config.AUTO_APPROVE_THRESHOLD_EUR:.2f}"
|
||||
)
|
||||
|
||||
# Rule 2: Supplier trust score check
|
||||
if supplier_data and self.config.AUTO_APPROVE_TRUSTED_SUPPLIERS:
|
||||
supplier_score = supplier_data.get('trust_score', 0.0)
|
||||
is_preferred = supplier_data.get('is_preferred_supplier', False)
|
||||
auto_approve_enabled = supplier_data.get('auto_approve_enabled', False)
|
||||
|
||||
if supplier_score < self.config.AUTO_APPROVE_MIN_SUPPLIER_SCORE:
|
||||
should_approve = False
|
||||
reasons.append(
|
||||
f"Supplier trust score {supplier_score:.2f} below minimum {self.config.AUTO_APPROVE_MIN_SUPPLIER_SCORE:.2f}"
|
||||
)
|
||||
else:
|
||||
reasons.append(f"Supplier trust score {supplier_score:.2f} meets minimum requirements")
|
||||
|
||||
if not is_preferred:
|
||||
should_approve = False
|
||||
reasons.append("Supplier is not marked as preferred")
|
||||
else:
|
||||
reasons.append("Supplier is a preferred supplier")
|
||||
|
||||
if not auto_approve_enabled:
|
||||
should_approve = False
|
||||
reasons.append("Auto-approve is disabled for this supplier")
|
||||
else:
|
||||
reasons.append("Auto-approve is enabled for this supplier")
|
||||
|
||||
elif supplier_data is None:
|
||||
should_approve = False
|
||||
reasons.append("No supplier data available")
|
||||
|
||||
# Rule 3: New supplier check
|
||||
if supplier_data and self.config.REQUIRE_APPROVAL_NEW_SUPPLIERS:
|
||||
total_pos = supplier_data.get('total_pos_count', 0)
|
||||
if total_pos < 5:
|
||||
should_approve = False
|
||||
reasons.append(f"New supplier with only {total_pos} previous orders (minimum 5 required)")
|
||||
else:
|
||||
reasons.append(f"Established supplier with {total_pos} previous orders")
|
||||
|
||||
# Rule 4: Critical/urgent items check
|
||||
if requirements_data and self.config.REQUIRE_APPROVAL_CRITICAL_ITEMS:
|
||||
critical_count = sum(
|
||||
1 for req in requirements_data
|
||||
if req.get('priority') in ['critical', 'urgent', 'CRITICAL', 'URGENT']
|
||||
)
|
||||
if critical_count > 0:
|
||||
should_approve = False
|
||||
reasons.append(f"Contains {critical_count} critical/urgent items requiring manual review")
|
||||
else:
|
||||
reasons.append("No critical/urgent items detected")
|
||||
|
||||
# Rule 5: Historical approval rate check
|
||||
if supplier_data:
|
||||
total_pos = supplier_data.get('total_pos_count', 0)
|
||||
approved_pos = supplier_data.get('approved_pos_count', 0)
|
||||
|
||||
if total_pos > 0:
|
||||
approval_rate = approved_pos / total_pos
|
||||
if approval_rate < 0.95:
|
||||
should_approve = False
|
||||
reasons.append(
|
||||
f"Historical approval rate {approval_rate:.1%} below 95% threshold"
|
||||
)
|
||||
else:
|
||||
reasons.append(f"High historical approval rate {approval_rate:.1%}")
|
||||
|
||||
# Rule 6: PO priority check
|
||||
priority = po_data.get('priority', 'normal')
|
||||
if priority in ['urgent', 'critical', 'URGENT', 'CRITICAL']:
|
||||
should_approve = False
|
||||
reasons.append(f"PO priority is '{priority}' - requires manual review")
|
||||
|
||||
logger.info(
|
||||
"PO auto-approval evaluation completed",
|
||||
should_auto_approve=should_approve,
|
||||
total_amount=float(total_amount),
|
||||
supplier_id=supplier_data.get('id') if supplier_data else None,
|
||||
reasons_count=len(reasons),
|
||||
po_data=po_data.get('id') if isinstance(po_data, dict) else str(po_data)
|
||||
)
|
||||
|
||||
return should_approve, reasons
|
||||
|
||||
def _calculate_po_total(self, po_data: Dict[str, Any]) -> Decimal:
|
||||
"""Calculate total PO amount including tax and shipping"""
|
||||
subtotal = Decimal(str(po_data.get('subtotal', 0)))
|
||||
tax = Decimal(str(po_data.get('tax_amount', 0)))
|
||||
shipping = Decimal(str(po_data.get('shipping_cost', 0)))
|
||||
discount = Decimal(str(po_data.get('discount_amount', 0)))
|
||||
|
||||
total = subtotal + tax + shipping - discount
|
||||
return total
|
||||
|
||||
def get_approval_summary(
|
||||
self,
|
||||
should_approve: bool,
|
||||
reasons: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a human-readable approval summary
|
||||
|
||||
Returns:
|
||||
Dict with summary data for UI display
|
||||
"""
|
||||
return {
|
||||
"auto_approved": should_approve,
|
||||
"decision": "APPROVED" if should_approve else "REQUIRES_MANUAL_APPROVAL",
|
||||
"reasons": reasons,
|
||||
"reason_count": len(reasons),
|
||||
"summary": self._format_summary(should_approve, reasons)
|
||||
}
|
||||
|
||||
def _format_summary(self, should_approve: bool, reasons: List[str]) -> str:
|
||||
"""Format approval decision summary"""
|
||||
if should_approve:
|
||||
return f"Auto-approved: {', '.join(reasons[:2])}"
|
||||
else:
|
||||
failing_reasons = [r for r in reasons if any(
|
||||
keyword in r.lower()
|
||||
for keyword in ['exceeds', 'below', 'not', 'disabled', 'new', 'critical']
|
||||
)]
|
||||
if failing_reasons:
|
||||
return f"Manual approval required: {failing_reasons[0]}"
|
||||
return "Manual approval required"
|
||||
|
||||
def validate_approval_override(
|
||||
self,
|
||||
override_reason: str,
|
||||
user_role: str
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate if a user can override auto-approval decision
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Only admin/owner can override
|
||||
if user_role not in ['admin', 'owner']:
|
||||
return False, "Insufficient permissions to override approval rules"
|
||||
|
||||
# Require a reason
|
||||
if not override_reason or len(override_reason.strip()) < 10:
|
||||
return False, "Override reason must be at least 10 characters"
|
||||
|
||||
return True, None
|
||||
257
services/orders/app/services/procurement_notification_service.py
Normal file
257
services/orders/app/services/procurement_notification_service.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# services/orders/app/services/procurement_notification_service.py
|
||||
"""
|
||||
Procurement Notification Service - Send alerts and notifications for procurement events
|
||||
Handles PO approval notifications, reminders, escalations, and summaries
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import structlog
|
||||
|
||||
from shared.config.base import BaseServiceSettings
|
||||
from shared.alerts.base_service import AlertServiceMixin
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ProcurementNotificationService(AlertServiceMixin):
|
||||
"""Service for sending procurement-related notifications and alerts"""
|
||||
|
||||
def __init__(self, config: BaseServiceSettings):
|
||||
self.config = config
|
||||
|
||||
async def send_pos_pending_approval_alert(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
pos_data: List[Dict[str, Any]]
|
||||
):
|
||||
"""
|
||||
Send alert when new POs are created and need approval
|
||||
Groups POs and sends a summary notification
|
||||
"""
|
||||
try:
|
||||
if not pos_data:
|
||||
return
|
||||
|
||||
# Calculate totals
|
||||
total_amount = sum(float(po.get('total_amount', 0)) for po in pos_data)
|
||||
critical_count = sum(1 for po in pos_data if po.get('priority') in ['high', 'critical', 'urgent'])
|
||||
|
||||
# Determine severity based on amount and urgency
|
||||
severity = "medium"
|
||||
if critical_count > 0 or total_amount > 5000:
|
||||
severity = "high"
|
||||
elif total_amount > 10000:
|
||||
severity = "critical"
|
||||
|
||||
alert_data = {
|
||||
"type": "procurement_pos_pending_approval",
|
||||
"severity": severity,
|
||||
"title": f"{len(pos_data)} Pedidos Pendientes de Aprobación",
|
||||
"message": f"Se han creado {len(pos_data)} pedidos de compra que requieren tu aprobación. Total: €{total_amount:.2f}",
|
||||
"metadata": {
|
||||
"tenant_id": str(tenant_id),
|
||||
"pos_count": len(pos_data),
|
||||
"total_amount": total_amount,
|
||||
"critical_count": critical_count,
|
||||
"pos": [
|
||||
{
|
||||
"po_id": po.get("po_id"),
|
||||
"po_number": po.get("po_number"),
|
||||
"supplier_id": po.get("supplier_id"),
|
||||
"total_amount": po.get("total_amount"),
|
||||
"auto_approved": po.get("auto_approved", False)
|
||||
}
|
||||
for po in pos_data
|
||||
],
|
||||
"action_required": True,
|
||||
"action_url": "/app/comprar"
|
||||
}
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, alert_data, item_type='alert')
|
||||
|
||||
logger.info("POs pending approval alert sent",
|
||||
tenant_id=str(tenant_id),
|
||||
pos_count=len(pos_data),
|
||||
total_amount=total_amount)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending POs pending approval alert",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
async def send_approval_reminder(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
po_data: Dict[str, Any],
|
||||
hours_pending: int
|
||||
):
|
||||
"""
|
||||
Send reminder for POs that haven't been approved within threshold
|
||||
"""
|
||||
try:
|
||||
alert_data = {
|
||||
"type": "procurement_approval_reminder",
|
||||
"severity": "medium" if hours_pending < 36 else "high",
|
||||
"title": f"Recordatorio: Pedido {po_data.get('po_number')} Pendiente",
|
||||
"message": f"El pedido {po_data.get('po_number')} lleva {hours_pending} horas sin aprobarse. Total: €{po_data.get('total_amount', 0):.2f}",
|
||||
"metadata": {
|
||||
"tenant_id": str(tenant_id),
|
||||
"po_id": po_data.get("po_id"),
|
||||
"po_number": po_data.get("po_number"),
|
||||
"supplier_name": po_data.get("supplier_name"),
|
||||
"total_amount": po_data.get("total_amount"),
|
||||
"hours_pending": hours_pending,
|
||||
"created_at": po_data.get("created_at"),
|
||||
"action_required": True,
|
||||
"action_url": f"/app/comprar?po={po_data.get('po_id')}"
|
||||
}
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, alert_data, item_type='alert')
|
||||
|
||||
logger.info("Approval reminder sent",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=po_data.get("po_id"),
|
||||
hours_pending=hours_pending)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending approval reminder",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=po_data.get("po_id"),
|
||||
error=str(e))
|
||||
|
||||
async def send_critical_po_escalation(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
po_data: Dict[str, Any],
|
||||
hours_pending: int
|
||||
):
|
||||
"""
|
||||
Send escalation alert for critical/urgent POs not approved in time
|
||||
"""
|
||||
try:
|
||||
alert_data = {
|
||||
"type": "procurement_critical_po",
|
||||
"severity": "critical",
|
||||
"title": f"🚨 URGENTE: Pedido Crítico {po_data.get('po_number')}",
|
||||
"message": f"El pedido crítico {po_data.get('po_number')} lleva {hours_pending} horas sin aprobar. Se requiere acción inmediata.",
|
||||
"metadata": {
|
||||
"tenant_id": str(tenant_id),
|
||||
"po_id": po_data.get("po_id"),
|
||||
"po_number": po_data.get("po_number"),
|
||||
"supplier_name": po_data.get("supplier_name"),
|
||||
"total_amount": po_data.get("total_amount"),
|
||||
"priority": po_data.get("priority"),
|
||||
"required_delivery_date": po_data.get("required_delivery_date"),
|
||||
"hours_pending": hours_pending,
|
||||
"escalated": True,
|
||||
"action_required": True,
|
||||
"action_url": f"/app/comprar?po={po_data.get('po_id')}"
|
||||
}
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, alert_data, item_type='alert')
|
||||
|
||||
logger.warning("Critical PO escalation sent",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=po_data.get("po_id"),
|
||||
hours_pending=hours_pending)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending critical PO escalation",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=po_data.get("po_id"),
|
||||
error=str(e))
|
||||
|
||||
async def send_auto_approval_summary(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
summary_data: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Send daily summary of auto-approved POs
|
||||
"""
|
||||
try:
|
||||
auto_approved_count = summary_data.get("auto_approved_count", 0)
|
||||
total_amount = summary_data.get("total_auto_approved_amount", 0)
|
||||
manual_approval_count = summary_data.get("manual_approval_count", 0)
|
||||
|
||||
if auto_approved_count == 0 and manual_approval_count == 0:
|
||||
# No activity, skip notification
|
||||
return
|
||||
|
||||
alert_data = {
|
||||
"type": "procurement_auto_approval_summary",
|
||||
"severity": "low",
|
||||
"title": "Resumen Diario de Pedidos",
|
||||
"message": f"Hoy se aprobaron automáticamente {auto_approved_count} pedidos (€{total_amount:.2f}). {manual_approval_count} requieren aprobación manual.",
|
||||
"metadata": {
|
||||
"tenant_id": str(tenant_id),
|
||||
"auto_approved_count": auto_approved_count,
|
||||
"total_auto_approved_amount": total_amount,
|
||||
"manual_approval_count": manual_approval_count,
|
||||
"summary_date": summary_data.get("date"),
|
||||
"auto_approved_pos": summary_data.get("auto_approved_pos", []),
|
||||
"pending_approval_pos": summary_data.get("pending_approval_pos", []),
|
||||
"action_url": "/app/comprar"
|
||||
}
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, alert_data, item_type='notification')
|
||||
|
||||
logger.info("Auto-approval summary sent",
|
||||
tenant_id=str(tenant_id),
|
||||
auto_approved_count=auto_approved_count,
|
||||
manual_count=manual_approval_count)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending auto-approval summary",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
async def send_po_approved_confirmation(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
po_data: Dict[str, Any],
|
||||
approved_by: str,
|
||||
auto_approved: bool = False
|
||||
):
|
||||
"""
|
||||
Send confirmation when a PO is approved
|
||||
"""
|
||||
try:
|
||||
approval_type = "automáticamente" if auto_approved else f"por {approved_by}"
|
||||
|
||||
alert_data = {
|
||||
"type": "procurement_po_approved",
|
||||
"severity": "low",
|
||||
"title": f"Pedido {po_data.get('po_number')} Aprobado",
|
||||
"message": f"El pedido {po_data.get('po_number')} ha sido aprobado {approval_type}. Total: €{po_data.get('total_amount', 0):.2f}",
|
||||
"metadata": {
|
||||
"tenant_id": str(tenant_id),
|
||||
"po_id": po_data.get("po_id"),
|
||||
"po_number": po_data.get("po_number"),
|
||||
"supplier_name": po_data.get("supplier_name"),
|
||||
"total_amount": po_data.get("total_amount"),
|
||||
"approved_by": approved_by,
|
||||
"auto_approved": auto_approved,
|
||||
"approved_at": datetime.now(timezone.utc).isoformat(),
|
||||
"action_url": f"/app/comprar?po={po_data.get('po_id')}"
|
||||
}
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, alert_data, item_type='notification')
|
||||
|
||||
logger.info("PO approved confirmation sent",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=po_data.get("po_id"),
|
||||
auto_approved=auto_approved)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending PO approved confirmation",
|
||||
tenant_id=str(tenant_id),
|
||||
po_id=po_data.get("po_id"),
|
||||
error=str(e))
|
||||
@@ -297,16 +297,24 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
|
||||
result = await procurement_service.generate_procurement_plan(tenant_id, request)
|
||||
|
||||
if result.success and result.plan:
|
||||
# Send notification about new plan
|
||||
await self.send_procurement_notification(
|
||||
tenant_id, result.plan, "plan_created"
|
||||
)
|
||||
|
||||
logger.info("🎉 Procurement plan created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
plan_id=str(result.plan.id),
|
||||
plan_date=str(planning_date),
|
||||
total_requirements=result.plan.total_requirements)
|
||||
|
||||
# Auto-create POs from the plan (NEW FEATURE)
|
||||
if self.config.AUTO_CREATE_POS_FROM_PLAN:
|
||||
await self._auto_create_purchase_orders_from_plan(
|
||||
procurement_service,
|
||||
tenant_id,
|
||||
result.plan.id
|
||||
)
|
||||
|
||||
# Send notification about new plan
|
||||
await self.send_procurement_notification(
|
||||
tenant_id, result.plan, "plan_created"
|
||||
)
|
||||
else:
|
||||
logger.warning("⚠️ Failed to generate procurement plan",
|
||||
tenant_id=str(tenant_id),
|
||||
@@ -400,6 +408,70 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
|
||||
notification_type=notification_type,
|
||||
error=str(e))
|
||||
|
||||
async def _auto_create_purchase_orders_from_plan(
|
||||
self,
|
||||
procurement_service,
|
||||
tenant_id: UUID,
|
||||
plan_id: UUID
|
||||
):
|
||||
"""
|
||||
Automatically create purchase orders from procurement plan
|
||||
Integrates with auto-approval rules
|
||||
"""
|
||||
try:
|
||||
logger.info("🛒 Auto-creating purchase orders from plan",
|
||||
tenant_id=str(tenant_id),
|
||||
plan_id=str(plan_id))
|
||||
|
||||
# Create POs with auto-approval evaluation enabled
|
||||
po_result = await procurement_service.create_purchase_orders_from_plan(
|
||||
tenant_id=tenant_id,
|
||||
plan_id=plan_id,
|
||||
auto_approve=True # Enable auto-approval evaluation
|
||||
)
|
||||
|
||||
if po_result.get("success"):
|
||||
total_created = po_result.get("total_created", 0)
|
||||
auto_approved = po_result.get("total_auto_approved", 0)
|
||||
pending_approval = po_result.get("total_pending_approval", 0)
|
||||
|
||||
logger.info("✅ Purchase orders created from plan",
|
||||
tenant_id=str(tenant_id),
|
||||
plan_id=str(plan_id),
|
||||
total_created=total_created,
|
||||
auto_approved=auto_approved,
|
||||
pending_approval=pending_approval)
|
||||
|
||||
# Send notifications
|
||||
from app.services.procurement_notification_service import ProcurementNotificationService
|
||||
notification_service = ProcurementNotificationService(self.config)
|
||||
|
||||
# Notify about pending approvals
|
||||
if pending_approval > 0:
|
||||
await notification_service.send_pos_pending_approval_alert(
|
||||
tenant_id=tenant_id,
|
||||
pos_data=po_result.get("pending_approval_pos", [])
|
||||
)
|
||||
|
||||
# Log auto-approved POs for summary
|
||||
if auto_approved > 0:
|
||||
logger.info("🤖 Auto-approved POs",
|
||||
tenant_id=str(tenant_id),
|
||||
count=auto_approved,
|
||||
pos=po_result.get("auto_approved_pos", []))
|
||||
|
||||
else:
|
||||
logger.error("❌ Failed to create purchase orders from plan",
|
||||
tenant_id=str(tenant_id),
|
||||
plan_id=str(plan_id),
|
||||
error=po_result.get("error"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("💥 Error auto-creating purchase orders",
|
||||
tenant_id=str(tenant_id),
|
||||
plan_id=str(plan_id),
|
||||
error=str(e))
|
||||
|
||||
async def test_procurement_generation(self):
|
||||
"""Test method to manually trigger procurement planning"""
|
||||
# Get the first available tenant for testing
|
||||
|
||||
@@ -482,8 +482,9 @@ class ProcurementService:
|
||||
auto_approve: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create purchase orders from procurement plan requirements (Feature #1)
|
||||
Create purchase orders from procurement plan requirements with smart auto-approval
|
||||
Groups requirements by supplier and creates POs automatically
|
||||
Evaluates auto-approval rules and approves qualifying POs
|
||||
"""
|
||||
try:
|
||||
plan = await self.plan_repo.get_plan_by_id(plan_id, tenant_id)
|
||||
@@ -493,6 +494,10 @@ class ProcurementService:
|
||||
if not plan.requirements:
|
||||
return {"success": False, "error": "No requirements in plan"}
|
||||
|
||||
# Import approval rules service
|
||||
from app.services.approval_rules_service import ApprovalRulesService
|
||||
approval_service = ApprovalRulesService(self.config)
|
||||
|
||||
# Group requirements by supplier
|
||||
supplier_requirements = {}
|
||||
for req in plan.requirements:
|
||||
@@ -507,6 +512,8 @@ class ProcurementService:
|
||||
# Create PO for each supplier
|
||||
created_pos = []
|
||||
failed_pos = []
|
||||
auto_approved_pos = []
|
||||
pending_approval_pos = []
|
||||
|
||||
for supplier_id, requirements in supplier_requirements.items():
|
||||
if supplier_id == 'no_supplier':
|
||||
@@ -530,7 +537,19 @@ class ProcurementService:
|
||||
for item in po_items
|
||||
)
|
||||
|
||||
# Create PO via suppliers service
|
||||
# Get supplier data for approval evaluation
|
||||
supplier_data = await self._get_supplier_performance_data(tenant_id, supplier_id)
|
||||
|
||||
# Prepare requirements data for approval evaluation
|
||||
requirements_data = [
|
||||
{
|
||||
"priority": req.priority,
|
||||
"risk_level": req.risk_level
|
||||
}
|
||||
for req in requirements
|
||||
]
|
||||
|
||||
# Create PO data structure
|
||||
po_data = {
|
||||
"supplier_id": supplier_id,
|
||||
"items": po_items,
|
||||
@@ -539,9 +558,26 @@ class ProcurementService:
|
||||
"notes": f"Auto-generated from procurement plan {plan.plan_number}",
|
||||
"tax_amount": subtotal * 0.1, # 10% tax estimation
|
||||
"shipping_cost": 0,
|
||||
"discount_amount": 0
|
||||
"discount_amount": 0,
|
||||
"subtotal": subtotal
|
||||
}
|
||||
|
||||
# Evaluate auto-approval rules
|
||||
should_auto_approve = False
|
||||
approval_reasons = []
|
||||
|
||||
if auto_approve and self.config.AUTO_CREATE_POS_FROM_PLAN:
|
||||
should_auto_approve, approval_reasons = await approval_service.evaluate_po_for_auto_approval(
|
||||
po_data=po_data,
|
||||
supplier_data=supplier_data,
|
||||
requirements_data=requirements_data
|
||||
)
|
||||
|
||||
# Add approval metadata to PO
|
||||
po_data["auto_approval_evaluated"] = True
|
||||
po_data["auto_approval_decision"] = "APPROVED" if should_auto_approve else "REQUIRES_MANUAL_APPROVAL"
|
||||
po_data["auto_approval_reasons"] = approval_reasons
|
||||
|
||||
# Call suppliers service to create PO
|
||||
po_response = await self.suppliers_client.create_purchase_order(
|
||||
str(tenant_id),
|
||||
@@ -563,15 +599,48 @@ class ProcurementService:
|
||||
expected_delivery_date=req.required_by_date
|
||||
)
|
||||
|
||||
# Auto-approve PO if rules pass
|
||||
if should_auto_approve:
|
||||
await self._auto_approve_purchase_order(
|
||||
tenant_id=tenant_id,
|
||||
po_id=po_id,
|
||||
approval_reasons=approval_reasons
|
||||
)
|
||||
auto_approved_pos.append({
|
||||
"po_id": po_id,
|
||||
"po_number": po_number,
|
||||
"supplier_id": supplier_id,
|
||||
"items_count": len(requirements),
|
||||
"total_amount": subtotal,
|
||||
"auto_approved": True,
|
||||
"approval_reasons": approval_reasons
|
||||
})
|
||||
logger.info("PO auto-approved", po_id=po_id, supplier_id=supplier_id)
|
||||
else:
|
||||
pending_approval_pos.append({
|
||||
"po_id": po_id,
|
||||
"po_number": po_number,
|
||||
"supplier_id": supplier_id,
|
||||
"items_count": len(requirements),
|
||||
"total_amount": subtotal,
|
||||
"auto_approved": False,
|
||||
"requires_manual_approval": True,
|
||||
"approval_reasons": approval_reasons
|
||||
})
|
||||
|
||||
created_pos.append({
|
||||
"po_id": po_id,
|
||||
"po_number": po_number,
|
||||
"supplier_id": supplier_id,
|
||||
"items_count": len(requirements),
|
||||
"total_amount": subtotal
|
||||
"total_amount": subtotal,
|
||||
"auto_approved": should_auto_approve
|
||||
})
|
||||
|
||||
logger.info("PO created from plan", po_id=po_id, supplier_id=supplier_id)
|
||||
logger.info("PO created from plan",
|
||||
po_id=po_id,
|
||||
supplier_id=supplier_id,
|
||||
auto_approved=should_auto_approve)
|
||||
else:
|
||||
failed_pos.append({
|
||||
"supplier_id": supplier_id,
|
||||
@@ -589,11 +658,21 @@ class ProcurementService:
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("PO creation from plan completed",
|
||||
total_created=len(created_pos),
|
||||
auto_approved=len(auto_approved_pos),
|
||||
pending_approval=len(pending_approval_pos),
|
||||
failed=len(failed_pos))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"created_pos": created_pos,
|
||||
"failed_pos": failed_pos,
|
||||
"auto_approved_pos": auto_approved_pos,
|
||||
"pending_approval_pos": pending_approval_pos,
|
||||
"total_created": len(created_pos),
|
||||
"total_auto_approved": len(auto_approved_pos),
|
||||
"total_pending_approval": len(pending_approval_pos),
|
||||
"total_failed": len(failed_pos)
|
||||
}
|
||||
|
||||
@@ -1612,3 +1691,75 @@ class ProcurementService:
|
||||
"plan_completion_rate": 0.0,
|
||||
"supplier_performance": 0.0
|
||||
}
|
||||
|
||||
async def _get_supplier_performance_data(self, tenant_id: uuid.UUID, supplier_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get supplier performance data from suppliers service
|
||||
Used for auto-approval evaluation
|
||||
"""
|
||||
try:
|
||||
# Call suppliers service to get supplier with performance metrics
|
||||
supplier_data = await self.suppliers_client.get_supplier(str(tenant_id), supplier_id)
|
||||
|
||||
if not supplier_data:
|
||||
logger.warning("Supplier not found", supplier_id=supplier_id)
|
||||
return None
|
||||
|
||||
# Extract relevant performance fields
|
||||
return {
|
||||
"id": supplier_data.get("id"),
|
||||
"name": supplier_data.get("name"),
|
||||
"trust_score": supplier_data.get("trust_score", 0.0),
|
||||
"is_preferred_supplier": supplier_data.get("is_preferred_supplier", False),
|
||||
"auto_approve_enabled": supplier_data.get("auto_approve_enabled", False),
|
||||
"total_pos_count": supplier_data.get("total_pos_count", 0),
|
||||
"approved_pos_count": supplier_data.get("approved_pos_count", 0),
|
||||
"on_time_delivery_rate": supplier_data.get("on_time_delivery_rate", 0.0),
|
||||
"fulfillment_rate": supplier_data.get("fulfillment_rate", 0.0),
|
||||
"quality_rating": supplier_data.get("quality_rating", 0.0),
|
||||
"delivery_rating": supplier_data.get("delivery_rating", 0.0),
|
||||
"status": supplier_data.get("status")
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier performance data",
|
||||
supplier_id=supplier_id,
|
||||
error=str(e))
|
||||
return None
|
||||
|
||||
async def _auto_approve_purchase_order(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_id: str,
|
||||
approval_reasons: List[str]
|
||||
):
|
||||
"""
|
||||
Auto-approve a purchase order via suppliers service
|
||||
Updates PO status to 'approved' with system approval
|
||||
"""
|
||||
try:
|
||||
# Call suppliers service to approve the PO
|
||||
approval_data = {
|
||||
"approved_by": "system", # System auto-approval
|
||||
"approval_notes": f"Auto-approved: {', '.join(approval_reasons[:2])}",
|
||||
"auto_approved": True,
|
||||
"approval_reasons": approval_reasons
|
||||
}
|
||||
|
||||
await self.suppliers_client.approve_purchase_order(
|
||||
str(tenant_id),
|
||||
po_id,
|
||||
approval_data
|
||||
)
|
||||
|
||||
logger.info("PO auto-approved successfully",
|
||||
po_id=po_id,
|
||||
tenant_id=str(tenant_id),
|
||||
reasons_count=len(approval_reasons))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error auto-approving PO",
|
||||
po_id=po_id,
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user