Improve the frontend 3
This commit is contained in:
444
services/procurement/app/services/shelf_life_manager.py
Normal file
444
services/procurement/app/services/shelf_life_manager.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Shelf Life Manager
|
||||
|
||||
Manages shelf life constraints for perishable ingredients to minimize waste
|
||||
and ensure food safety.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import statistics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShelfLifeConstraint:
|
||||
"""Shelf life constraints for an ingredient"""
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
shelf_life_days: int
|
||||
is_perishable: bool
|
||||
category: str # 'fresh', 'frozen', 'dry', 'canned'
|
||||
max_order_quantity_days: Optional[int] = None # Max days worth to order at once
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShelfLifeAdjustment:
|
||||
"""Result of shelf life adjustment"""
|
||||
original_quantity: Decimal
|
||||
adjusted_quantity: Decimal
|
||||
adjustment_reason: str
|
||||
waste_risk: str # 'low', 'medium', 'high'
|
||||
recommended_order_date: date
|
||||
use_by_date: date
|
||||
is_constrained: bool
|
||||
|
||||
|
||||
class ShelfLifeManager:
|
||||
"""
|
||||
Manages procurement planning considering shelf life constraints.
|
||||
|
||||
For perishable items:
|
||||
1. Don't order too far in advance (will expire)
|
||||
2. Don't order too much at once (will waste)
|
||||
3. Calculate optimal order timing
|
||||
4. Warn about expiration risks
|
||||
"""
|
||||
|
||||
# Category-specific defaults
|
||||
CATEGORY_DEFAULTS = {
|
||||
'fresh': {
|
||||
'max_days_ahead': 2,
|
||||
'max_order_days_supply': 3,
|
||||
'waste_risk_threshold': 0.80
|
||||
},
|
||||
'dairy': {
|
||||
'max_days_ahead': 3,
|
||||
'max_order_days_supply': 5,
|
||||
'waste_risk_threshold': 0.85
|
||||
},
|
||||
'frozen': {
|
||||
'max_days_ahead': 14,
|
||||
'max_order_days_supply': 30,
|
||||
'waste_risk_threshold': 0.90
|
||||
},
|
||||
'dry': {
|
||||
'max_days_ahead': 90,
|
||||
'max_order_days_supply': 90,
|
||||
'waste_risk_threshold': 0.95
|
||||
},
|
||||
'canned': {
|
||||
'max_days_ahead': 180,
|
||||
'max_order_days_supply': 180,
|
||||
'waste_risk_threshold': 0.95
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, waste_risk_threshold: float = 0.85):
|
||||
"""
|
||||
Initialize shelf life manager.
|
||||
|
||||
Args:
|
||||
waste_risk_threshold: % of shelf life before considering waste risk
|
||||
"""
|
||||
self.waste_risk_threshold = waste_risk_threshold
|
||||
|
||||
def adjust_order_quantity_for_shelf_life(
|
||||
self,
|
||||
ingredient_id: str,
|
||||
ingredient_name: str,
|
||||
requested_quantity: Decimal,
|
||||
daily_consumption_rate: float,
|
||||
shelf_life_days: int,
|
||||
category: str = 'dry',
|
||||
is_perishable: bool = True,
|
||||
delivery_date: Optional[date] = None
|
||||
) -> ShelfLifeAdjustment:
|
||||
"""
|
||||
Adjust order quantity to prevent waste due to expiration.
|
||||
|
||||
Args:
|
||||
ingredient_id: Ingredient ID
|
||||
ingredient_name: Ingredient name
|
||||
requested_quantity: Requested order quantity
|
||||
daily_consumption_rate: Average daily usage
|
||||
shelf_life_days: Days until expiration
|
||||
category: Ingredient category
|
||||
is_perishable: Whether item is perishable
|
||||
delivery_date: Expected delivery date
|
||||
|
||||
Returns:
|
||||
ShelfLifeAdjustment with adjusted quantity
|
||||
"""
|
||||
if not is_perishable:
|
||||
# Non-perishable, no adjustment needed
|
||||
return ShelfLifeAdjustment(
|
||||
original_quantity=requested_quantity,
|
||||
adjusted_quantity=requested_quantity,
|
||||
adjustment_reason='Non-perishable item, no shelf life constraint',
|
||||
waste_risk='low',
|
||||
recommended_order_date=delivery_date or date.today(),
|
||||
use_by_date=delivery_date + timedelta(days=365) if delivery_date else date.today() + timedelta(days=365),
|
||||
is_constrained=False
|
||||
)
|
||||
|
||||
if delivery_date is None:
|
||||
delivery_date = date.today()
|
||||
|
||||
# Get category defaults
|
||||
defaults = self.CATEGORY_DEFAULTS.get(
|
||||
category.lower(),
|
||||
self.CATEGORY_DEFAULTS['dry']
|
||||
)
|
||||
|
||||
# Calculate use by date
|
||||
use_by_date = delivery_date + timedelta(days=shelf_life_days)
|
||||
|
||||
# Calculate how many days the requested quantity will last
|
||||
if daily_consumption_rate > 0:
|
||||
days_supply = float(requested_quantity) / daily_consumption_rate
|
||||
else:
|
||||
days_supply = 0
|
||||
|
||||
# Calculate maximum safe quantity (using waste risk threshold)
|
||||
safe_shelf_life_days = int(shelf_life_days * self.waste_risk_threshold)
|
||||
max_safe_quantity = Decimal(str(daily_consumption_rate * safe_shelf_life_days))
|
||||
|
||||
# Check if adjustment needed
|
||||
is_constrained = requested_quantity > max_safe_quantity
|
||||
adjusted_quantity = requested_quantity
|
||||
|
||||
if is_constrained:
|
||||
adjusted_quantity = max_safe_quantity
|
||||
adjustment_reason = (
|
||||
f"Reduced from {requested_quantity} to {adjusted_quantity} to fit within "
|
||||
f"{safe_shelf_life_days}-day safe consumption window (shelf life: {shelf_life_days} days)"
|
||||
)
|
||||
logger.warning(
|
||||
f"{ingredient_name}: Order quantity reduced due to shelf life constraint "
|
||||
f"({requested_quantity} → {adjusted_quantity})"
|
||||
)
|
||||
else:
|
||||
adjustment_reason = "Quantity within safe shelf life window"
|
||||
|
||||
# Calculate waste risk
|
||||
waste_risk = self._calculate_waste_risk(
|
||||
days_supply=days_supply,
|
||||
shelf_life_days=shelf_life_days,
|
||||
threshold=defaults['waste_risk_threshold']
|
||||
)
|
||||
|
||||
return ShelfLifeAdjustment(
|
||||
original_quantity=requested_quantity,
|
||||
adjusted_quantity=adjusted_quantity,
|
||||
adjustment_reason=adjustment_reason,
|
||||
waste_risk=waste_risk,
|
||||
recommended_order_date=delivery_date - timedelta(days=defaults['max_days_ahead']),
|
||||
use_by_date=use_by_date,
|
||||
is_constrained=is_constrained
|
||||
)
|
||||
|
||||
def calculate_optimal_order_date(
|
||||
self,
|
||||
required_by_date: date,
|
||||
shelf_life_days: int,
|
||||
category: str = 'dry',
|
||||
lead_time_days: int = 0
|
||||
) -> Tuple[date, str]:
|
||||
"""
|
||||
Calculate optimal order date considering shelf life.
|
||||
|
||||
Args:
|
||||
required_by_date: When item is needed
|
||||
shelf_life_days: Shelf life in days
|
||||
category: Ingredient category
|
||||
lead_time_days: Supplier lead time
|
||||
|
||||
Returns:
|
||||
Tuple of (optimal_order_date, reasoning)
|
||||
"""
|
||||
defaults = self.CATEGORY_DEFAULTS.get(
|
||||
category.lower(),
|
||||
self.CATEGORY_DEFAULTS['dry']
|
||||
)
|
||||
|
||||
# Calculate delivery date accounting for lead time
|
||||
delivery_date = required_by_date - timedelta(days=lead_time_days)
|
||||
|
||||
# For perishables, don't deliver too far in advance
|
||||
max_advance_days = min(
|
||||
defaults['max_days_ahead'],
|
||||
int(shelf_life_days * 0.3) # Max 30% of shelf life
|
||||
)
|
||||
|
||||
# Optimal delivery: close to required date but not too early
|
||||
optimal_delivery_date = required_by_date - timedelta(days=max_advance_days)
|
||||
|
||||
# Optimal order date
|
||||
optimal_order_date = optimal_delivery_date - timedelta(days=lead_time_days)
|
||||
|
||||
reasoning = (
|
||||
f"Order placed {lead_time_days} days before delivery "
|
||||
f"(arrives {max_advance_days} days before use to maintain freshness)"
|
||||
)
|
||||
|
||||
return optimal_order_date, reasoning
|
||||
|
||||
def validate_order_timing(
|
||||
self,
|
||||
order_date: date,
|
||||
delivery_date: date,
|
||||
required_by_date: date,
|
||||
shelf_life_days: int,
|
||||
ingredient_name: str
|
||||
) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate order timing against shelf life constraints.
|
||||
|
||||
Args:
|
||||
order_date: Planned order date
|
||||
delivery_date: Expected delivery date
|
||||
required_by_date: Date when item is needed
|
||||
shelf_life_days: Shelf life in days
|
||||
ingredient_name: Name of ingredient
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list of warnings)
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
# Check if item will arrive in time
|
||||
if delivery_date > required_by_date:
|
||||
warnings.append(
|
||||
f"Delivery date {delivery_date} is after required date {required_by_date}"
|
||||
)
|
||||
|
||||
# Check if item will expire before use
|
||||
expiry_date = delivery_date + timedelta(days=shelf_life_days)
|
||||
if expiry_date < required_by_date:
|
||||
warnings.append(
|
||||
f"Item will expire on {expiry_date} before required date {required_by_date}"
|
||||
)
|
||||
|
||||
# Check if ordering too far in advance
|
||||
days_in_storage = (required_by_date - delivery_date).days
|
||||
if days_in_storage > shelf_life_days * 0.8:
|
||||
warnings.append(
|
||||
f"Item will be in storage for {days_in_storage} days "
|
||||
f"(80% of {shelf_life_days}-day shelf life)"
|
||||
)
|
||||
|
||||
is_valid = len(warnings) == 0
|
||||
|
||||
if not is_valid:
|
||||
for warning in warnings:
|
||||
logger.warning(f"{ingredient_name}: {warning}")
|
||||
|
||||
return is_valid, warnings
|
||||
|
||||
def calculate_fifo_rotation_schedule(
|
||||
self,
|
||||
current_inventory: List[Dict],
|
||||
new_order_quantity: Decimal,
|
||||
delivery_date: date,
|
||||
daily_consumption: float
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Calculate FIFO (First In First Out) rotation schedule.
|
||||
|
||||
Args:
|
||||
current_inventory: List of existing batches with expiry dates
|
||||
new_order_quantity: New order quantity
|
||||
delivery_date: New order delivery date
|
||||
daily_consumption: Daily consumption rate
|
||||
|
||||
Returns:
|
||||
List of usage schedule
|
||||
"""
|
||||
# Combine current and new inventory
|
||||
all_batches = []
|
||||
|
||||
for batch in current_inventory:
|
||||
all_batches.append({
|
||||
'quantity': batch['quantity'],
|
||||
'expiry_date': batch['expiry_date'],
|
||||
'is_existing': True
|
||||
})
|
||||
|
||||
# Add new order (estimate shelf life from existing batches)
|
||||
if current_inventory:
|
||||
avg_shelf_life_days = statistics.mean([
|
||||
(batch['expiry_date'] - date.today()).days
|
||||
for batch in current_inventory
|
||||
])
|
||||
else:
|
||||
avg_shelf_life_days = 30
|
||||
|
||||
all_batches.append({
|
||||
'quantity': new_order_quantity,
|
||||
'expiry_date': delivery_date + timedelta(days=int(avg_shelf_life_days)),
|
||||
'is_existing': False
|
||||
})
|
||||
|
||||
# Sort by expiry date (FIFO)
|
||||
all_batches.sort(key=lambda x: x['expiry_date'])
|
||||
|
||||
# Create consumption schedule
|
||||
schedule = []
|
||||
current_date = date.today()
|
||||
remaining_consumption = daily_consumption
|
||||
|
||||
for batch in all_batches:
|
||||
days_until_expiry = (batch['expiry_date'] - current_date).days
|
||||
batch_quantity = float(batch['quantity'])
|
||||
|
||||
# Calculate days to consume this batch
|
||||
days_to_consume = min(
|
||||
batch_quantity / daily_consumption,
|
||||
days_until_expiry
|
||||
)
|
||||
|
||||
quantity_consumed = days_to_consume * daily_consumption
|
||||
waste = max(0, batch_quantity - quantity_consumed)
|
||||
|
||||
schedule.append({
|
||||
'start_date': current_date,
|
||||
'end_date': current_date + timedelta(days=int(days_to_consume)),
|
||||
'quantity': batch['quantity'],
|
||||
'quantity_consumed': Decimal(str(quantity_consumed)),
|
||||
'quantity_wasted': Decimal(str(waste)),
|
||||
'expiry_date': batch['expiry_date'],
|
||||
'is_existing': batch['is_existing']
|
||||
})
|
||||
|
||||
current_date += timedelta(days=int(days_to_consume))
|
||||
|
||||
return schedule
|
||||
|
||||
def _calculate_waste_risk(
|
||||
self,
|
||||
days_supply: float,
|
||||
shelf_life_days: int,
|
||||
threshold: float
|
||||
) -> str:
|
||||
"""
|
||||
Calculate waste risk level.
|
||||
|
||||
Args:
|
||||
days_supply: Days of supply ordered
|
||||
shelf_life_days: Shelf life in days
|
||||
threshold: Waste risk threshold
|
||||
|
||||
Returns:
|
||||
Risk level: 'low', 'medium', 'high'
|
||||
"""
|
||||
if days_supply <= shelf_life_days * threshold * 0.5:
|
||||
return 'low'
|
||||
elif days_supply <= shelf_life_days * threshold:
|
||||
return 'medium'
|
||||
else:
|
||||
return 'high'
|
||||
|
||||
def get_expiration_alerts(
|
||||
self,
|
||||
inventory_batches: List[Dict],
|
||||
alert_days_threshold: int = 3
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get alerts for batches expiring soon.
|
||||
|
||||
Args:
|
||||
inventory_batches: List of batches with expiry dates
|
||||
alert_days_threshold: Days before expiry to alert
|
||||
|
||||
Returns:
|
||||
List of expiration alerts
|
||||
"""
|
||||
alerts = []
|
||||
today = date.today()
|
||||
|
||||
for batch in inventory_batches:
|
||||
expiry_date = batch.get('expiry_date')
|
||||
if not expiry_date:
|
||||
continue
|
||||
|
||||
days_until_expiry = (expiry_date - today).days
|
||||
|
||||
if days_until_expiry <= alert_days_threshold:
|
||||
alerts.append({
|
||||
'ingredient_id': batch.get('ingredient_id'),
|
||||
'ingredient_name': batch.get('ingredient_name'),
|
||||
'quantity': batch.get('quantity'),
|
||||
'expiry_date': expiry_date,
|
||||
'days_until_expiry': days_until_expiry,
|
||||
'severity': 'critical' if days_until_expiry <= 1 else 'high'
|
||||
})
|
||||
|
||||
if alerts:
|
||||
logger.warning(f"Found {len(alerts)} batches expiring within {alert_days_threshold} days")
|
||||
|
||||
return alerts
|
||||
|
||||
def export_to_dict(self, adjustment: ShelfLifeAdjustment) -> Dict:
|
||||
"""
|
||||
Export adjustment to dictionary for API response.
|
||||
|
||||
Args:
|
||||
adjustment: ShelfLifeAdjustment
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
'original_quantity': float(adjustment.original_quantity),
|
||||
'adjusted_quantity': float(adjustment.adjusted_quantity),
|
||||
'adjustment_reason': adjustment.adjustment_reason,
|
||||
'waste_risk': adjustment.waste_risk,
|
||||
'recommended_order_date': adjustment.recommended_order_date.isoformat(),
|
||||
'use_by_date': adjustment.use_by_date.isoformat(),
|
||||
'is_constrained': adjustment.is_constrained
|
||||
}
|
||||
Reference in New Issue
Block a user