Improve the frontend modals

This commit is contained in:
Urtzi Alfaro
2025-10-27 16:33:26 +01:00
parent 61376b7a9f
commit 858d985c92
143 changed files with 9289 additions and 2306 deletions

View File

@@ -60,11 +60,10 @@ class OrdersService:
self.production_client = production_client
self.sales_client = sales_client
@transactional
async def create_order(
self,
db,
order_data: OrderCreate,
self,
db,
order_data: OrderCreate,
user_id: Optional[UUID] = None
) -> OrderResponse:
"""Create a new customer order with comprehensive processing"""
@@ -170,7 +169,6 @@ class OrdersService:
error=str(e))
raise
@transactional
async def update_order_status(
self,
db,
@@ -358,10 +356,15 @@ class OrdersService:
# Detect business model
business_model = await self.detect_business_model(db, tenant_id)
# Calculate performance metrics
fulfillment_rate = Decimal("95.0") # Calculate from actual data
on_time_delivery_rate = Decimal("92.0") # Calculate from actual data
repeat_customers_rate = Decimal("65.0") # Calculate from actual data
# Calculate performance metrics from actual data
fulfillment_rate = metrics.get("fulfillment_rate", Decimal("0.0")) # Use actual calculated rate
on_time_delivery_rate = metrics.get("on_time_delivery_rate", Decimal("0.0")) # Use actual calculated rate
repeat_customers_rate = metrics.get("repeat_customers_rate", Decimal("0.0")) # Use actual calculated rate
# Use the actual calculated values from the repository
order_fulfillment_rate = metrics.get("fulfillment_rate", Decimal("0.0"))
on_time_delivery_rate_metric = metrics.get("on_time_delivery_rate", Decimal("0.0"))
repeat_customers_rate_metric = metrics.get("repeat_customers_rate", Decimal("0.0"))
return OrdersDashboardSummary(
total_orders_today=metrics["total_orders_today"],
@@ -377,10 +380,10 @@ class OrdersService:
delivered_orders=metrics["status_breakdown"].get("delivered", 0),
total_customers=total_customers,
new_customers_this_month=new_customers_this_month,
repeat_customers_rate=repeat_customers_rate,
repeat_customers_rate=repeat_customers_rate_metric,
average_order_value=metrics["average_order_value"],
order_fulfillment_rate=fulfillment_rate,
on_time_delivery_rate=on_time_delivery_rate,
order_fulfillment_rate=order_fulfillment_rate,
on_time_delivery_rate=on_time_delivery_rate_metric,
business_model=business_model,
business_model_confidence=Decimal("85.0") if business_model else None,
recent_orders=[OrderResponse.from_orm(order) for order in recent_orders],
@@ -480,4 +483,3 @@ class OrdersService:
logger.warning("Failed to send status notification",
order_id=str(order.id),
error=str(e))

View File

@@ -29,6 +29,8 @@ from shared.config.base import BaseServiceSettings
from shared.messaging.rabbitmq import RabbitMQClient
from shared.monitoring.decorators import monitor_performance
from app.services.cache_service import get_cache_service, CacheService
from app.services.smart_procurement_calculator import SmartProcurementCalculator
from shared.utils.tenant_settings_client import TenantSettingsClient
logger = structlog.get_logger()
@@ -56,6 +58,10 @@ class ProcurementService:
self.suppliers_client = suppliers_client or SuppliersServiceClient(config)
self.cache_service = cache_service or get_cache_service()
# Initialize tenant settings client
tenant_service_url = getattr(config, 'TENANT_SERVICE_URL', 'http://tenant-service:8000')
self.tenant_settings_client = TenantSettingsClient(tenant_service_url=tenant_service_url)
# Initialize RabbitMQ client
rabbitmq_url = getattr(config, 'RABBITMQ_URL', 'amqp://guest:guest@localhost:5672/')
self.rabbitmq_client = RabbitMQClient(rabbitmq_url, "orders-service")
@@ -951,10 +957,17 @@ class ProcurementService:
seasonality_factor: float = 1.0
) -> List[Dict[str, Any]]:
"""
Create procurement requirements data with supplier integration (Bug #1 FIX)
Create procurement requirements data with smart hybrid calculation
Combines AI forecasting with ingredient reorder rules and supplier constraints
"""
requirements = []
# Get tenant procurement settings
procurement_settings = await self.tenant_settings_client.get_procurement_settings(tenant_id)
# Initialize smart calculator
calculator = SmartProcurementCalculator(procurement_settings)
for item in inventory_items:
item_id = item.get('id')
if not item_id or item_id not in forecasts:
@@ -963,27 +976,41 @@ class ProcurementService:
forecast = forecasts[item_id]
current_stock = Decimal(str(item.get('current_stock', 0)))
# Get predicted demand and apply seasonality (Feature #4)
# Get predicted demand and apply seasonality
base_predicted_demand = Decimal(str(forecast.get('predicted_demand', 0)))
predicted_demand = base_predicted_demand * Decimal(str(seasonality_factor))
# Calculate safety stock
safety_stock = predicted_demand * (request.safety_stock_percentage / 100)
total_needed = predicted_demand + safety_stock
# Round up to avoid under-ordering
total_needed_rounded = Decimal(str(math.ceil(float(total_needed))))
# Round up AI forecast to avoid under-ordering
predicted_demand_rounded = Decimal(str(math.ceil(float(predicted_demand))))
safety_stock_rounded = total_needed_rounded - predicted_demand_rounded
net_requirement = max(Decimal('0'), total_needed_rounded - current_stock)
# Get best supplier and price list for this product
best_supplier = await self._get_best_supplier_for_product(
tenant_id, item_id, suppliers
)
if net_requirement > 0:
# Bug #1 FIX: Get best supplier for this product
best_supplier = await self._get_best_supplier_for_product(
tenant_id, item_id, suppliers
)
# Get price list entry if supplier exists
price_list_entry = None
if best_supplier and best_supplier.get('price_lists'):
for pl in best_supplier.get('price_lists', []):
if pl.get('inventory_product_id') == item_id:
price_list_entry = pl
break
# Use smart calculator to determine optimal order quantity
calc_result = calculator.calculate_procurement_quantity(
ingredient=item,
supplier=best_supplier,
price_list_entry=price_list_entry,
ai_forecast_quantity=predicted_demand_rounded,
current_stock=current_stock,
safety_stock_percentage=request.safety_stock_percentage
)
# Extract calculation results
order_quantity = calc_result['order_quantity']
# Only create requirement if there's a positive order quantity
if order_quantity > 0:
requirement_number = await self.requirement_repo.generate_requirement_number(plan_id)
required_by_date = request.plan_date or date.today()
@@ -994,22 +1021,22 @@ class ProcurementService:
suggested_order_date = required_by_date - timedelta(days=lead_time_days)
latest_order_date = required_by_date - timedelta(days=1)
# Calculate expected delivery date
expected_delivery_date = suggested_order_date + timedelta(days=lead_time_days)
# Calculate priority and risk
priority = self._calculate_priority(net_requirement, current_stock, item)
# Calculate safety stock quantities
safety_stock_qty = order_quantity * (request.safety_stock_percentage / Decimal('100'))
total_needed = predicted_demand_rounded + safety_stock_qty
# Calculate priority and risk (using the adjusted quantity now)
priority = self._calculate_priority(order_quantity, current_stock, item)
risk_level = self._calculate_risk_level(item, forecast)
# Get supplier pricing if available
estimated_unit_cost = Decimal(str(item.get('avg_cost', 0)))
if best_supplier and best_supplier.get('pricing'):
# Try to find pricing for this product
supplier_price = best_supplier.get('pricing', {}).get(item_id)
if supplier_price:
estimated_unit_cost = Decimal(str(supplier_price))
# Get supplier pricing
estimated_unit_cost = Decimal(str(item.get('average_cost') or item.get('avg_cost', 0)))
if price_list_entry:
estimated_unit_cost = Decimal(str(price_list_entry.get('unit_price', estimated_unit_cost)))
# Build requirement data with smart calculation metadata
requirement_data = {
'plan_id': plan_id,
'requirement_number': requirement_number,
@@ -1019,14 +1046,14 @@ class ProcurementService:
'product_category': item.get('category', ''),
'product_type': 'product',
'required_quantity': predicted_demand_rounded,
'unit_of_measure': item.get('unit', 'units'),
'safety_stock_quantity': safety_stock_rounded,
'total_quantity_needed': total_needed_rounded,
'unit_of_measure': item.get('unit_of_measure') or item.get('unit', 'units'),
'safety_stock_quantity': safety_stock_qty,
'total_quantity_needed': total_needed,
'current_stock_level': current_stock,
'available_stock': current_stock,
'net_requirement': net_requirement,
'net_requirement': order_quantity,
'forecast_demand': predicted_demand_rounded,
'buffer_demand': safety_stock_rounded,
'buffer_demand': safety_stock_qty,
'required_by_date': required_by_date,
'suggested_order_date': suggested_order_date,
'latest_order_date': latest_order_date,
@@ -1038,12 +1065,21 @@ class ProcurementService:
'ordered_quantity': Decimal('0'),
'received_quantity': Decimal('0'),
'estimated_unit_cost': estimated_unit_cost,
'estimated_total_cost': net_requirement * estimated_unit_cost,
# Bug #1 FIX: Add supplier information
'estimated_total_cost': order_quantity * estimated_unit_cost,
'preferred_supplier_id': uuid.UUID(best_supplier['id']) if best_supplier and best_supplier.get('id') else None,
'supplier_name': best_supplier.get('name') if best_supplier else None,
'supplier_lead_time_days': lead_time_days,
'minimum_order_quantity': Decimal(str(best_supplier.get('minimum_order_quantity', 0))) if best_supplier else None,
'minimum_order_quantity': Decimal(str(price_list_entry.get('minimum_order_quantity', 0))) if price_list_entry else None,
# Smart procurement calculation metadata
'calculation_method': calc_result.get('calculation_method'),
'ai_suggested_quantity': calc_result.get('ai_suggested_quantity'),
'adjusted_quantity': calc_result.get('adjusted_quantity'),
'adjustment_reason': calc_result.get('adjustment_reason'),
'price_tier_applied': calc_result.get('price_tier_applied'),
'supplier_minimum_applied': calc_result.get('supplier_minimum_applied', False),
'storage_limit_applied': calc_result.get('storage_limit_applied', False),
'reorder_rule_applied': calc_result.get('reorder_rule_applied', False),
}
requirements.append(requirement_data)

View File

@@ -0,0 +1,339 @@
# services/orders/app/services/smart_procurement_calculator.py
"""
Smart Procurement Calculator
Implements multi-constraint procurement quantity optimization combining:
- AI demand forecasting
- Ingredient reorder rules (reorder_point, reorder_quantity)
- Supplier constraints (minimum_order_quantity, minimum_order_amount)
- Storage limits (max_stock_level)
- Price tier optimization
"""
import math
from decimal import Decimal
from typing import Dict, Any, List, Tuple, Optional
import structlog
logger = structlog.get_logger()
class SmartProcurementCalculator:
"""
Smart procurement quantity calculator with multi-tier constraint optimization
"""
def __init__(self, procurement_settings: Dict[str, Any]):
"""
Initialize calculator with tenant procurement settings
Args:
procurement_settings: Tenant settings dict with flags:
- use_reorder_rules: bool
- economic_rounding: bool
- respect_storage_limits: bool
- use_supplier_minimums: bool
- optimize_price_tiers: bool
"""
self.use_reorder_rules = procurement_settings.get('use_reorder_rules', True)
self.economic_rounding = procurement_settings.get('economic_rounding', True)
self.respect_storage_limits = procurement_settings.get('respect_storage_limits', True)
self.use_supplier_minimums = procurement_settings.get('use_supplier_minimums', True)
self.optimize_price_tiers = procurement_settings.get('optimize_price_tiers', True)
def calculate_procurement_quantity(
self,
ingredient: Dict[str, Any],
supplier: Optional[Dict[str, Any]],
price_list_entry: Optional[Dict[str, Any]],
ai_forecast_quantity: Decimal,
current_stock: Decimal,
safety_stock_percentage: Decimal = Decimal('20.0')
) -> Dict[str, Any]:
"""
Calculate optimal procurement quantity using smart hybrid approach
Args:
ingredient: Ingredient data with reorder_point, reorder_quantity, max_stock_level
supplier: Supplier data with minimum_order_amount
price_list_entry: Price list with minimum_order_quantity, tier_pricing
ai_forecast_quantity: AI-predicted demand quantity
current_stock: Current stock level
safety_stock_percentage: Safety stock buffer percentage
Returns:
Dict with:
- order_quantity: Final calculated quantity to order
- calculation_method: Method used (e.g., 'REORDER_POINT_TRIGGERED')
- ai_suggested_quantity: Original AI forecast
- adjusted_quantity: Final quantity after constraints
- adjustment_reason: Human-readable explanation
- warnings: List of warnings/notes
- supplier_minimum_applied: bool
- storage_limit_applied: bool
- reorder_rule_applied: bool
- price_tier_applied: Dict or None
"""
warnings = []
result = {
'ai_suggested_quantity': ai_forecast_quantity,
'supplier_minimum_applied': False,
'storage_limit_applied': False,
'reorder_rule_applied': False,
'price_tier_applied': None
}
# Extract ingredient parameters
reorder_point = Decimal(str(ingredient.get('reorder_point', 0)))
reorder_quantity = Decimal(str(ingredient.get('reorder_quantity', 0)))
low_stock_threshold = Decimal(str(ingredient.get('low_stock_threshold', 0)))
max_stock_level = Decimal(str(ingredient.get('max_stock_level') or 'Infinity'))
# Extract supplier/price list parameters
supplier_min_qty = Decimal('0')
supplier_min_amount = Decimal('0')
tier_pricing = []
if price_list_entry:
supplier_min_qty = Decimal(str(price_list_entry.get('minimum_order_quantity', 0)))
tier_pricing = price_list_entry.get('tier_pricing') or []
if supplier:
supplier_min_amount = Decimal(str(supplier.get('minimum_order_amount', 0)))
# Calculate AI-based net requirement with safety stock
safety_stock = ai_forecast_quantity * (safety_stock_percentage / Decimal('100'))
total_needed = ai_forecast_quantity + safety_stock
ai_net_requirement = max(Decimal('0'), total_needed - current_stock)
# TIER 1: Critical Safety Check (Emergency Override)
if self.use_reorder_rules and current_stock <= low_stock_threshold:
base_order = max(reorder_quantity, ai_net_requirement)
result['calculation_method'] = 'CRITICAL_STOCK_EMERGENCY'
result['reorder_rule_applied'] = True
warnings.append(f"CRITICAL: Stock ({current_stock}) below threshold ({low_stock_threshold})")
order_qty = base_order
# TIER 2: Reorder Point Triggered
elif self.use_reorder_rules and current_stock <= reorder_point:
base_order = max(reorder_quantity, ai_net_requirement)
result['calculation_method'] = 'REORDER_POINT_TRIGGERED'
result['reorder_rule_applied'] = True
warnings.append(f"Reorder point triggered: stock ({current_stock}) ≤ reorder point ({reorder_point})")
order_qty = base_order
# TIER 3: Forecast-Driven (Above reorder point, no immediate need)
elif ai_net_requirement > 0:
order_qty = ai_net_requirement
result['calculation_method'] = 'FORECAST_DRIVEN_PROACTIVE'
warnings.append(f"AI forecast suggests ordering {ai_net_requirement} units")
# TIER 4: No Order Needed
else:
result['order_quantity'] = Decimal('0')
result['adjusted_quantity'] = Decimal('0')
result['calculation_method'] = 'SUFFICIENT_STOCK'
result['adjustment_reason'] = f"Current stock ({current_stock}) is sufficient. No order needed."
result['warnings'] = warnings
return result
# Apply Economic Rounding (reorder_quantity multiples)
if self.economic_rounding and reorder_quantity > 0:
multiples = math.ceil(float(order_qty / reorder_quantity))
rounded_qty = Decimal(multiples) * reorder_quantity
if rounded_qty > order_qty:
warnings.append(f"Rounded to {multiples}× reorder quantity ({reorder_quantity}) = {rounded_qty}")
order_qty = rounded_qty
# Apply Supplier Minimum Quantity Constraint
if self.use_supplier_minimums and supplier_min_qty > 0:
if order_qty < supplier_min_qty:
warnings.append(f"Increased from {order_qty} to supplier minimum ({supplier_min_qty})")
order_qty = supplier_min_qty
result['supplier_minimum_applied'] = True
else:
# Round to multiples of minimum_order_quantity (packaging constraint)
multiples = math.ceil(float(order_qty / supplier_min_qty))
rounded_qty = Decimal(multiples) * supplier_min_qty
if rounded_qty > order_qty:
warnings.append(f"Rounded to {multiples}× supplier packaging ({supplier_min_qty}) = {rounded_qty}")
result['supplier_minimum_applied'] = True
order_qty = rounded_qty
# Apply Price Tier Optimization
if self.optimize_price_tiers and tier_pricing and price_list_entry:
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
tier_result = self._optimize_price_tier(
order_qty,
unit_price,
tier_pricing,
current_stock,
max_stock_level
)
if tier_result['tier_applied']:
order_qty = tier_result['optimized_quantity']
result['price_tier_applied'] = tier_result['tier_info']
warnings.append(tier_result['message'])
# Apply Storage Capacity Constraint
if self.respect_storage_limits and max_stock_level != Decimal('Infinity'):
if (current_stock + order_qty) > max_stock_level:
capped_qty = max(Decimal('0'), max_stock_level - current_stock)
warnings.append(f"Capped from {order_qty} to {capped_qty} due to storage limit ({max_stock_level})")
order_qty = capped_qty
result['storage_limit_applied'] = True
result['calculation_method'] += '_STORAGE_LIMITED'
# Check supplier minimum_order_amount (total order value constraint)
if self.use_supplier_minimums and supplier_min_amount > 0 and price_list_entry:
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
order_value = order_qty * unit_price
if order_value < supplier_min_amount:
warnings.append(
f"⚠️ Order value €{order_value:.2f} < supplier minimum €{supplier_min_amount:.2f}. "
"This item needs to be combined with other products in the same PO."
)
result['calculation_method'] += '_NEEDS_CONSOLIDATION'
# Build final result
result['order_quantity'] = order_qty
result['adjusted_quantity'] = order_qty
result['adjustment_reason'] = self._build_adjustment_reason(
ai_forecast_quantity,
ai_net_requirement,
order_qty,
warnings,
result
)
result['warnings'] = warnings
return result
def _optimize_price_tier(
self,
current_qty: Decimal,
base_unit_price: Decimal,
tier_pricing: List[Dict[str, Any]],
current_stock: Decimal,
max_stock_level: Decimal
) -> Dict[str, Any]:
"""
Optimize order quantity to capture volume discount tiers if beneficial
Args:
current_qty: Current calculated order quantity
base_unit_price: Base unit price without tiers
tier_pricing: List of tier dicts with 'quantity' and 'price'
current_stock: Current stock level
max_stock_level: Maximum storage capacity
Returns:
Dict with tier_applied (bool), optimized_quantity, tier_info, message
"""
if not tier_pricing:
return {'tier_applied': False, 'optimized_quantity': current_qty}
# Sort tiers by quantity
sorted_tiers = sorted(tier_pricing, key=lambda x: x['quantity'])
best_tier = None
best_savings = Decimal('0')
for tier in sorted_tiers:
tier_qty = Decimal(str(tier['quantity']))
tier_price = Decimal(str(tier['price']))
# Skip if tier quantity is below current quantity (already captured)
if tier_qty <= current_qty:
continue
# Skip if tier would exceed storage capacity
if self.respect_storage_limits and (current_stock + tier_qty) > max_stock_level:
continue
# Skip if tier is more than 50% above current quantity (too much excess)
if tier_qty > current_qty * Decimal('1.5'):
continue
# Calculate savings
current_cost = current_qty * base_unit_price
tier_cost = tier_qty * tier_price
savings = current_cost - tier_cost
if savings > best_savings:
best_savings = savings
best_tier = {
'quantity': tier_qty,
'price': tier_price,
'savings': savings
}
if best_tier:
return {
'tier_applied': True,
'optimized_quantity': best_tier['quantity'],
'tier_info': best_tier,
'message': (
f"Upgraded to {best_tier['quantity']} units "
f"@ €{best_tier['price']}/unit "
f"(saves €{best_tier['savings']:.2f})"
)
}
return {'tier_applied': False, 'optimized_quantity': current_qty}
def _build_adjustment_reason(
self,
ai_forecast: Decimal,
ai_net_requirement: Decimal,
final_quantity: Decimal,
warnings: List[str],
result: Dict[str, Any]
) -> str:
"""
Build human-readable explanation of quantity adjustments
Args:
ai_forecast: Original AI forecast
ai_net_requirement: AI forecast + safety stock - current stock
final_quantity: Final order quantity after all adjustments
warnings: List of warning messages
result: Calculation result dict
Returns:
Human-readable adjustment explanation
"""
parts = []
# Start with calculation method
method = result.get('calculation_method', 'UNKNOWN')
parts.append(f"Method: {method.replace('_', ' ').title()}")
# AI forecast base
parts.append(f"AI Forecast: {ai_forecast} units, Net Requirement: {ai_net_requirement} units")
# Adjustments applied
adjustments = []
if result.get('reorder_rule_applied'):
adjustments.append("reorder rules")
if result.get('supplier_minimum_applied'):
adjustments.append("supplier minimums")
if result.get('storage_limit_applied'):
adjustments.append("storage limits")
if result.get('price_tier_applied'):
adjustments.append("price tier optimization")
if adjustments:
parts.append(f"Adjustments: {', '.join(adjustments)}")
# Final quantity
parts.append(f"Final Quantity: {final_quantity} units")
# Key warnings
if warnings:
key_warnings = [w for w in warnings if '⚠️' in w or 'CRITICAL' in w or 'saves €' in w]
if key_warnings:
parts.append(f"Notes: {'; '.join(key_warnings)}")
return " | ".join(parts)