Improve the frontend modals
This commit is contained in:
@@ -75,9 +75,6 @@ async def create_customer(
|
||||
):
|
||||
"""Create a new customer"""
|
||||
try:
|
||||
# Ensure tenant_id matches
|
||||
customer_data.tenant_id = tenant_id
|
||||
|
||||
# Check if customer code already exists
|
||||
existing_customer = await orders_service.customer_repo.get_by_customer_code(
|
||||
db, customer_data.customer_code, tenant_id
|
||||
@@ -88,12 +85,25 @@ async def create_customer(
|
||||
detail="Customer code already exists"
|
||||
)
|
||||
|
||||
# Extract user ID safely
|
||||
user_id = current_user.get("user_id")
|
||||
if not user_id:
|
||||
logger.error("User ID not found in current_user context", current_user_keys=list(current_user.keys()))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User authentication error"
|
||||
)
|
||||
|
||||
customer = await orders_service.customer_repo.create(
|
||||
db,
|
||||
obj_in=customer_data.dict(),
|
||||
created_by=UUID(current_user["sub"])
|
||||
obj_in=customer_data,
|
||||
created_by=UUID(user_id),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
# Commit the transaction to persist changes
|
||||
await db.commit()
|
||||
|
||||
logger.info("Customer created successfully",
|
||||
customer_id=str(customer.id),
|
||||
customer_code=customer.customer_code)
|
||||
@@ -202,13 +212,25 @@ async def update_customer(
|
||||
)
|
||||
|
||||
# Update customer
|
||||
# Extract user ID safely for update
|
||||
user_id = current_user.get("user_id")
|
||||
if not user_id:
|
||||
logger.error("User ID not found in current_user context for update", current_user_keys=list(current_user.keys()))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User authentication error"
|
||||
)
|
||||
|
||||
updated_customer = await orders_service.customer_repo.update(
|
||||
db,
|
||||
db_obj=customer,
|
||||
obj_in=customer_data.dict(exclude_unset=True),
|
||||
updated_by=UUID(current_user["sub"])
|
||||
updated_by=UUID(user_id)
|
||||
)
|
||||
|
||||
# Commit the transaction to persist changes
|
||||
await db.commit()
|
||||
|
||||
logger.info("Customer updated successfully",
|
||||
customer_id=str(customer_id))
|
||||
|
||||
@@ -262,6 +284,9 @@ async def delete_customer(
|
||||
|
||||
await orders_service.customer_repo.delete(db, customer_id, tenant_id)
|
||||
|
||||
# Commit the transaction to persist deletion
|
||||
await db.commit()
|
||||
|
||||
# Log HIGH severity audit event for customer deletion (GDPR compliance)
|
||||
try:
|
||||
await audit_logger.log_deletion(
|
||||
|
||||
@@ -76,15 +76,24 @@ async def create_order(
|
||||
):
|
||||
"""Create a new customer order"""
|
||||
try:
|
||||
# Ensure tenant_id matches
|
||||
order_data.tenant_id = tenant_id
|
||||
# Extract user ID safely
|
||||
user_id = current_user.get("user_id")
|
||||
if not user_id:
|
||||
logger.error("User ID not found in current_user context", current_user_keys=list(current_user.keys()))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User authentication error"
|
||||
)
|
||||
|
||||
order = await orders_service.create_order(
|
||||
db,
|
||||
order_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
user_id=UUID(user_id)
|
||||
)
|
||||
|
||||
# Commit the transaction to persist changes
|
||||
await db.commit()
|
||||
|
||||
logger.info("Order created successfully",
|
||||
order_id=str(order.id),
|
||||
order_number=order.order_number)
|
||||
@@ -211,6 +220,9 @@ async def update_order(
|
||||
updated_by=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
# Commit the transaction to persist changes
|
||||
await db.commit()
|
||||
|
||||
logger.info("Order updated successfully",
|
||||
order_id=str(order_id))
|
||||
|
||||
@@ -260,6 +272,9 @@ async def delete_order(
|
||||
|
||||
await orders_service.order_repo.delete(db, order_id, tenant_id)
|
||||
|
||||
# Commit the transaction to persist deletion
|
||||
await db.commit()
|
||||
|
||||
# Log audit event for order deletion
|
||||
try:
|
||||
await audit_logger.log_deletion(
|
||||
@@ -290,4 +305,4 @@ async def delete_order(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete order"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -195,7 +195,17 @@ class ProcurementRequirement(Base):
|
||||
source_orders = Column(JSONB, nullable=True) # Orders that contributed to this requirement
|
||||
source_production_batches = Column(JSONB, nullable=True) # Production batches needing this
|
||||
demand_analysis = Column(JSONB, nullable=True) # Detailed demand breakdown
|
||||
|
||||
|
||||
# Smart procurement calculation metadata
|
||||
calculation_method = Column(String(100), nullable=True) # Method used: REORDER_POINT_TRIGGERED, FORECAST_DRIVEN_PROACTIVE, etc.
|
||||
ai_suggested_quantity = Column(Numeric(12, 3), nullable=True) # Pure AI forecast quantity
|
||||
adjusted_quantity = Column(Numeric(12, 3), nullable=True) # Final quantity after applying constraints
|
||||
adjustment_reason = Column(Text, nullable=True) # Human-readable explanation of adjustments
|
||||
price_tier_applied = Column(JSONB, nullable=True) # Price tier information if applicable
|
||||
supplier_minimum_applied = Column(Boolean, nullable=False, default=False) # Whether supplier minimum was enforced
|
||||
storage_limit_applied = Column(Boolean, nullable=False, default=False) # Whether storage limit was hit
|
||||
reorder_rule_applied = Column(Boolean, nullable=False, default=False) # Whether reorder rules were used
|
||||
|
||||
# Approval and authorization
|
||||
approved_quantity = Column(Numeric(12, 3), nullable=True)
|
||||
approved_cost = Column(Numeric(12, 2), nullable=True)
|
||||
|
||||
@@ -156,7 +156,8 @@ class BaseRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||
db: AsyncSession,
|
||||
*,
|
||||
obj_in: CreateSchemaType,
|
||||
created_by: Optional[UUID] = None
|
||||
created_by: Optional[UUID] = None,
|
||||
tenant_id: Optional[UUID] = None
|
||||
) -> ModelType:
|
||||
"""Create a new record"""
|
||||
try:
|
||||
@@ -166,6 +167,10 @@ class BaseRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||
else:
|
||||
obj_data = obj_in
|
||||
|
||||
# Add tenant_id if the model supports it and it's provided
|
||||
if tenant_id and hasattr(self.model, 'tenant_id'):
|
||||
obj_data['tenant_id'] = tenant_id
|
||||
|
||||
# Add created_by if the model supports it
|
||||
if created_by and hasattr(self.model, 'created_by'):
|
||||
obj_data['created_by'] = created_by
|
||||
@@ -281,4 +286,4 @@ class BaseRepository(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||
model=self.model.__name__,
|
||||
id=str(id),
|
||||
error=str(e))
|
||||
raise
|
||||
raise
|
||||
|
||||
@@ -402,6 +402,87 @@ class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
|
||||
)
|
||||
)
|
||||
|
||||
# Calculate repeat customers rate
|
||||
# Count customers who have made more than one order
|
||||
repeat_customers_query = await db.execute(
|
||||
select(func.count()).select_from(
|
||||
select(CustomerOrder.customer_id)
|
||||
.where(CustomerOrder.tenant_id == tenant_id)
|
||||
.group_by(CustomerOrder.customer_id)
|
||||
.having(func.count(CustomerOrder.id) > 1)
|
||||
.subquery()
|
||||
)
|
||||
)
|
||||
|
||||
total_customers_query = await db.execute(
|
||||
select(func.count(func.distinct(CustomerOrder.customer_id))).where(
|
||||
CustomerOrder.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
|
||||
repeat_customers_count = repeat_customers_query.scalar() or 0
|
||||
total_customers_count = total_customers_query.scalar() or 0
|
||||
|
||||
repeat_customers_rate = Decimal("0.0")
|
||||
if total_customers_count > 0:
|
||||
repeat_customers_rate = Decimal(str(repeat_customers_count)) / Decimal(str(total_customers_count))
|
||||
repeat_customers_rate = repeat_customers_rate * Decimal("100.0") # Convert to percentage
|
||||
|
||||
# Calculate order fulfillment rate
|
||||
total_orders_query = await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
CustomerOrder.tenant_id == tenant_id,
|
||||
CustomerOrder.status != "cancelled"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
fulfilled_orders_query = await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
CustomerOrder.tenant_id == tenant_id,
|
||||
CustomerOrder.status.in_(["delivered", "completed"])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
total_orders_count = total_orders_query.scalar() or 0
|
||||
fulfilled_orders_count = fulfilled_orders_query.scalar() or 0
|
||||
|
||||
fulfillment_rate = Decimal("0.0")
|
||||
if total_orders_count > 0:
|
||||
fulfillment_rate = Decimal(str(fulfilled_orders_count)) / Decimal(str(total_orders_count))
|
||||
fulfillment_rate = fulfillment_rate * Decimal("100.0") # Convert to percentage
|
||||
|
||||
# Calculate on-time delivery rate
|
||||
on_time_delivered_query = await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
CustomerOrder.tenant_id == tenant_id,
|
||||
CustomerOrder.status == "delivered",
|
||||
CustomerOrder.actual_delivery_date <= CustomerOrder.requested_delivery_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
total_delivered_query = await db.execute(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
CustomerOrder.tenant_id == tenant_id,
|
||||
CustomerOrder.status == "delivered"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
on_time_delivered_count = on_time_delivered_query.scalar() or 0
|
||||
total_delivered_count = total_delivered_query.scalar() or 0
|
||||
|
||||
on_time_delivery_rate = Decimal("0.0")
|
||||
if total_delivered_count > 0:
|
||||
on_time_delivery_rate = Decimal(str(on_time_delivered_count)) / Decimal(str(total_delivered_count))
|
||||
on_time_delivery_rate = on_time_delivery_rate * Decimal("100.0") # Convert to percentage
|
||||
|
||||
return {
|
||||
"total_orders_today": orders_today.scalar(),
|
||||
"total_orders_this_week": orders_week.scalar(),
|
||||
@@ -410,7 +491,16 @@ class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
|
||||
"revenue_this_week": revenue_week.scalar(),
|
||||
"revenue_this_month": revenue_month.scalar(),
|
||||
"status_breakdown": status_breakdown,
|
||||
"average_order_value": avg_order_value.scalar()
|
||||
"average_order_value": avg_order_value.scalar(),
|
||||
"repeat_customers_rate": repeat_customers_rate,
|
||||
"fulfillment_rate": fulfillment_rate,
|
||||
"on_time_delivery_rate": on_time_delivery_rate,
|
||||
"repeat_customers_count": repeat_customers_count,
|
||||
"total_customers_count": total_customers_count,
|
||||
"total_orders_count": total_orders_count,
|
||||
"fulfilled_orders_count": fulfilled_orders_count,
|
||||
"on_time_delivered_count": on_time_delivered_count,
|
||||
"total_delivered_count": total_delivered_count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Error getting dashboard metrics", error=str(e))
|
||||
|
||||
@@ -51,7 +51,6 @@ class CustomerBase(BaseModel):
|
||||
|
||||
class CustomerCreate(CustomerBase):
|
||||
customer_code: str = Field(..., min_length=1, max_length=50)
|
||||
tenant_id: UUID
|
||||
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
@@ -288,7 +287,6 @@ class ProcurementPlanBase(BaseModel):
|
||||
|
||||
|
||||
class ProcurementPlanCreate(ProcurementPlanBase):
|
||||
tenant_id: UUID
|
||||
requirements: List[ProcurementRequirementCreate] = Field(..., min_items=1)
|
||||
|
||||
|
||||
@@ -395,4 +393,4 @@ class ProcurementPlanningData(BaseModel):
|
||||
|
||||
# Recommendations
|
||||
recommended_purchases: List[Dict[str, Any]]
|
||||
critical_shortages: List[Dict[str, Any]]
|
||||
critical_shortages: List[Dict[str, Any]]
|
||||
|
||||
@@ -75,6 +75,16 @@ class ProcurementRequirementCreate(ProcurementRequirementBase):
|
||||
quality_specifications: Optional[Dict[str, Any]] = None
|
||||
procurement_notes: Optional[str] = None
|
||||
|
||||
# Smart procurement calculation metadata
|
||||
calculation_method: Optional[str] = Field(None, max_length=100)
|
||||
ai_suggested_quantity: Optional[Decimal] = Field(None, ge=0)
|
||||
adjusted_quantity: Optional[Decimal] = Field(None, ge=0)
|
||||
adjustment_reason: Optional[str] = None
|
||||
price_tier_applied: Optional[Dict[str, Any]] = None
|
||||
supplier_minimum_applied: bool = False
|
||||
storage_limit_applied: bool = False
|
||||
reorder_rule_applied: bool = False
|
||||
|
||||
|
||||
class ProcurementRequirementUpdate(ProcurementBase):
|
||||
"""Schema for updating procurement requirements"""
|
||||
@@ -101,36 +111,46 @@ class ProcurementRequirementResponse(ProcurementRequirementBase):
|
||||
id: uuid.UUID
|
||||
plan_id: uuid.UUID
|
||||
requirement_number: str
|
||||
|
||||
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
purchase_order_id: Optional[uuid.UUID] = None
|
||||
purchase_order_number: Optional[str] = None
|
||||
ordered_quantity: Decimal
|
||||
ordered_at: Optional[datetime] = None
|
||||
|
||||
|
||||
expected_delivery_date: Optional[date] = None
|
||||
actual_delivery_date: Optional[date] = None
|
||||
received_quantity: Decimal
|
||||
delivery_status: str
|
||||
|
||||
|
||||
fulfillment_rate: Optional[Decimal] = None
|
||||
on_time_delivery: Optional[bool] = None
|
||||
quality_rating: Optional[Decimal] = None
|
||||
|
||||
|
||||
approved_quantity: Optional[Decimal] = None
|
||||
approved_cost: Optional[Decimal] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
approved_by: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
special_requirements: Optional[str] = None
|
||||
storage_requirements: Optional[str] = None
|
||||
shelf_life_days: Optional[int] = None
|
||||
quality_specifications: Optional[Dict[str, Any]] = None
|
||||
procurement_notes: Optional[str] = None
|
||||
|
||||
# Smart procurement calculation metadata
|
||||
calculation_method: Optional[str] = None
|
||||
ai_suggested_quantity: Optional[Decimal] = None
|
||||
adjusted_quantity: Optional[Decimal] = None
|
||||
adjustment_reason: Optional[str] = None
|
||||
price_tier_applied: Optional[Dict[str, Any]] = None
|
||||
supplier_minimum_applied: bool = False
|
||||
storage_limit_applied: bool = False
|
||||
reorder_rule_applied: bool = False
|
||||
|
||||
|
||||
# ================================================================
|
||||
# PROCUREMENT PLAN SCHEMAS
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
339
services/orders/app/services/smart_procurement_calculator.py
Normal file
339
services/orders/app/services/smart_procurement_calculator.py
Normal 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)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add smart procurement calculation fields
|
||||
|
||||
Revision ID: smart_procurement_v1
|
||||
Revises: 7f882c2ca25c
|
||||
Create Date: 2025-10-25
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'smart_procurement_v1'
|
||||
down_revision = '7f882c2ca25c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add smart procurement calculation tracking fields"""
|
||||
|
||||
# Add new columns to procurement_requirements table
|
||||
op.add_column('procurement_requirements', sa.Column('calculation_method', sa.String(100), nullable=True))
|
||||
op.add_column('procurement_requirements', sa.Column('ai_suggested_quantity', sa.Numeric(12, 3), nullable=True))
|
||||
op.add_column('procurement_requirements', sa.Column('adjusted_quantity', sa.Numeric(12, 3), nullable=True))
|
||||
op.add_column('procurement_requirements', sa.Column('adjustment_reason', sa.Text, nullable=True))
|
||||
op.add_column('procurement_requirements', sa.Column('price_tier_applied', JSONB, nullable=True))
|
||||
op.add_column('procurement_requirements', sa.Column('supplier_minimum_applied', sa.Boolean, nullable=False, server_default='false'))
|
||||
op.add_column('procurement_requirements', sa.Column('storage_limit_applied', sa.Boolean, nullable=False, server_default='false'))
|
||||
op.add_column('procurement_requirements', sa.Column('reorder_rule_applied', sa.Boolean, nullable=False, server_default='false'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove smart procurement calculation tracking fields"""
|
||||
|
||||
# Remove columns from procurement_requirements table
|
||||
op.drop_column('procurement_requirements', 'reorder_rule_applied')
|
||||
op.drop_column('procurement_requirements', 'storage_limit_applied')
|
||||
op.drop_column('procurement_requirements', 'supplier_minimum_applied')
|
||||
op.drop_column('procurement_requirements', 'price_tier_applied')
|
||||
op.drop_column('procurement_requirements', 'adjustment_reason')
|
||||
op.drop_column('procurement_requirements', 'adjusted_quantity')
|
||||
op.drop_column('procurement_requirements', 'ai_suggested_quantity')
|
||||
op.drop_column('procurement_requirements', 'calculation_method')
|
||||
Reference in New Issue
Block a user