Documentation Added: - AI_INSIGHTS_DEMO_SETUP_GUIDE.md: Complete setup guide for demo sessions - AI_INSIGHTS_DATA_FLOW.md: Architecture and data flow diagrams - AI_INSIGHTS_QUICK_START.md: Quick reference guide - DEMO_SESSION_ANALYSIS_REPORT.md: Detailed analysis of demo session d67eaae4 - ROOT_CAUSE_ANALYSIS_AND_FIXES.md: Complete analysis of 8 issues (6 fixed, 2 analyzed) - COMPLETE_FIX_SUMMARY.md: Executive summary of all fixes - FIX_MISSING_INSIGHTS.md: Forecasting and procurement fix guide - FINAL_STATUS_SUMMARY.md: Status overview - verify_fixes.sh: Automated verification script - enhance_procurement_data.py: Procurement data enhancement script Service Improvements: - Demo session cleanup worker: Use proper settings for Redis configuration with TLS/auth - Procurement service: Add Redis initialization with proper error handling and cleanup - Production fixture: Remove duplicate worker assignments (cleaned 56 duplicates) - Orchestrator fixture: Add purchase order metadata for better tracking Impact: - Complete documentation for troubleshooting and setup - Improved Redis connection handling across services - Clean production data without duplicates - Better error handling and logging 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Procurement Service
Overview
The Procurement Service automates ingredient purchasing by analyzing production schedules, inventory levels, and demand forecasts to generate optimized purchase orders. It prevents stockouts while minimizing excess inventory, manages supplier relationships with automated purchase order generation, and tracks delivery performance. This service is critical for maintaining optimal stock levels and ensuring continuous production operations.
Key Features
Automated Procurement Planning
- Intelligent Replenishment - Auto-calculate purchasing needs from production plans
- Forecast-Driven Planning - Use demand forecasts to anticipate ingredient needs
- Inventory Projection - Project stock levels 7-30 days ahead
- Lead Time Management - Account for supplier delivery times
- Safety Stock Calculation - Maintain buffers for critical ingredients
- Multi-Scenario Planning - Plan for normal, peak, and low demand periods
Purchase Order Management
- Automated PO Generation - One-click purchase order creation
- Supplier Allocation - Smart supplier selection based on price, quality, delivery
- PO Templates - Standard orders for recurring purchases
- Batch Ordering - Combine multiple ingredients per supplier
- Order Tracking - Monitor PO status from creation to delivery
- Order History - Complete purchase order archive
Supplier Integration
- Multi-Supplier Management - Handle 10+ suppliers per ingredient
- Price Comparison - Automatic best price selection
- Delivery Schedule - Track expected delivery dates
- Order Confirmation - Automated email/API confirmation to suppliers
- Performance Tracking - Monitor on-time delivery and quality
- Supplier Scorecards - Data-driven supplier evaluation
Purchase Order Workflow & Tracking (🆕)
- Dashboard-Integrated Approval - Approve/reject POs directly from dashboard action queue
- Progressive Disclosure - View PO details inline without navigation
- Automatic Supplier Notifications - Professional HTML emails sent on approval
- Delivery Date Tracking - Auto-calculate estimated delivery based on supplier lead time
- Color-Coded Status Indicators - Visual delivery tracking (green/yellow/red)
- Overdue Detection - Hourly background job detects late deliveries
- Severity Levels - Low (1d), Medium (2-3d), High (4-7d), Critical (7+ days overdue)
- Delivery Receipt Modal - Comprehensive delivery recording with batch/expiry tracking
- Automatic Stock Updates - Inventory updated automatically via events when deliveries recorded
Stock Optimization
- Reorder Point Calculation - When to order based on consumption rate
- Economic Order Quantity (EOQ) - Optimal order size calculation
- ABC Analysis - Prioritize critical ingredients
- Stockout Prevention - 85-95% stockout prevention rate
- Overstock Alerts - Warn against excessive inventory
- Seasonal Adjustment - Adjust for seasonal demand patterns
Cost Management
- Price Tracking - Monitor ingredient price trends over time
- Budget Management - Track spending against procurement budgets
- Cost Variance Analysis - Compare planned vs. actual costs
- Volume Discounts - Automatic discount application
- Contract Pricing - Manage fixed-price contracts with suppliers
- Cost Savings Reports - Quantify procurement optimization savings
Analytics & Reporting
- Procurement Dashboard - Real-time procurement KPIs
- Spend Analysis - Category and supplier spending breakdown
- Lead Time Analytics - Average delivery times per supplier
- Stockout Reports - Track missed orders due to stockouts
- Supplier Performance - On-time delivery and quality metrics
- ROI Tracking - Measure procurement efficiency gains
🆕 Enterprise Tier: Internal Transfers (NEW)
- Parent-Child Transfer Orders - Create internal purchase orders between central production and retail outlets
- Cost-Based Transfer Pricing - Calculate transfer prices based on actual production costs
- Recipe Cost Explosion - Automatic cost calculation from recipe ingredients for locally-produced items
- Average Cost Fallback - Use inventory average cost for purchased goods
- Markup Configuration - Optional markup on transfer prices (default 0%, configurable per tenant)
- Approval Workflow - Parent bakery must approve all internal transfer requests from children
- Integration with Distribution - Approved internal POs feed into delivery route optimization
- Inventory Coordination - Automatic inventory transfer on delivery completion via events
- Transfer Type Tracking - Distinguish between finished_goods and raw_materials transfers
- Enterprise Subscription Gating - Internal transfers require Enterprise tier subscription
Business Value
For Bakery Owners
- Stockout Prevention - Never miss production due to missing ingredients
- Cost Optimization - 5-15% procurement cost savings through automation
- Cash Flow Management - Optimize inventory investment
- Supplier Leverage - Data-driven supplier negotiations
- Time Savings - Automated ordering vs. manual tracking
- Compliance - Proper purchase order documentation for accounting
Quantifiable Impact
- Stockout Prevention: 85-95% reduction in production delays
- Cost Savings: 5-15% through optimized ordering and price comparison
- Time Savings: 8-12 hours/week on manual ordering and tracking
- Inventory Reduction: 20-30% lower inventory levels with same service
- Supplier Performance: 15-25% improvement in on-time delivery
- Waste Reduction: 10-20% less spoilage from excess inventory
For Procurement Staff
- Automated Calculations - System calculates what and when to order
- Supplier Insights - Best supplier recommendations with data
- Order Tracking - Visibility into all pending orders
- Exception Management - Focus on issues, not routine orders
- Performance Metrics - Clear KPIs for procurement efficiency
Technology Stack
- Framework: FastAPI (Python 3.11+) - Async web framework
- Database: PostgreSQL 17 - Procurement data
- Caching: Redis 7.4 - Calculation results cache
- Messaging: RabbitMQ 4.1 - Event publishing
- ORM: SQLAlchemy 2.0 (async) - Database abstraction
- Validation: Pydantic 2.0 - Schema validation
- Logging: Structlog - Structured JSON logging
- Metrics: Prometheus Client - Procurement metrics
API Endpoints (Key Routes)
Procurement Planning
GET /api/v1/procurement/needs- Calculate current procurement needsPOST /api/v1/procurement/needs/calculate- Trigger needs calculationGET /api/v1/procurement/needs/{need_id}- Get procurement need detailsGET /api/v1/procurement/projections- Get inventory projections
Purchase Orders
GET /api/v1/procurement/purchase-orders- List purchase ordersPOST /api/v1/procurement/purchase-orders- Create purchase orderGET /api/v1/procurement/purchase-orders/{po_id}- Get PO detailsPUT /api/v1/procurement/purchase-orders/{po_id}- Update POPOST /api/v1/procurement/purchase-orders/{po_id}/approve- Approve PO (triggers email)POST /api/v1/procurement/purchase-orders/{po_id}/reject- Reject PO with reasonPOST /api/v1/procurement/purchase-orders/{po_id}/send- Send PO to supplierPOST /api/v1/procurement/purchase-orders/{po_id}/receive- Mark PO receivedPOST /api/v1/procurement/purchase-orders/{po_id}/cancel- Cancel POGET /api/v1/procurement/purchase-orders/overdue- Get overdue POs (🆕)GET /api/v1/procurement/purchase-orders/{po_id}/overdue-status- Check if PO is overdue (🆕)
Deliveries (🆕)
POST /api/v1/procurement/purchase-orders/{po_id}/deliveries- Record delivery receiptGET /api/v1/procurement/purchase-orders/{po_id}/deliveries- List deliveries for POGET /api/v1/procurement/purchase-orders/{po_id}/deliveries/{delivery_id}- Get delivery detailsPATCH /api/v1/procurement/purchase-orders/{po_id}/deliveries/{delivery_id}/status- Update delivery status
Purchase Order Items
GET /api/v1/procurement/purchase-orders/{po_id}/items- List PO itemsPOST /api/v1/procurement/purchase-orders/{po_id}/items- Add item to POPUT /api/v1/procurement/purchase-orders/{po_id}/items/{item_id}- Update itemDELETE /api/v1/procurement/purchase-orders/{po_id}/items/{item_id}- Remove item
Supplier Management
GET /api/v1/procurement/suppliers/{supplier_id}/products- Supplier product catalogGET /api/v1/procurement/suppliers/{supplier_id}/pricing- Get supplier pricingPOST /api/v1/procurement/suppliers/{supplier_id}/pricing- Update pricingGET /api/v1/procurement/suppliers/recommend- Get supplier recommendations
Analytics
GET /api/v1/procurement/analytics/dashboard- Procurement dashboardGET /api/v1/procurement/analytics/spend- Spending analysisGET /api/v1/procurement/analytics/supplier-performance- Supplier metricsGET /api/v1/procurement/analytics/stockouts- Stockout analysisGET /api/v1/procurement/analytics/lead-times- Lead time analysis
🆕 Enterprise Internal Transfers (NEW)
POST /api/v1/tenants/{tenant_id}/procurement/internal-transfers- Create internal transfer POGET /api/v1/tenants/{tenant_id}/procurement/internal-transfers- List all internal transfersGET /api/v1/tenants/{tenant_id}/procurement/internal-transfers/pending- Get pending approvalsGET /api/v1/tenants/{tenant_id}/procurement/internal-transfers/history- Get transfer historyPUT /api/v1/tenants/{tenant_id}/procurement/internal-transfers/{po_id}/approve- Approve internal transfer (parent only)PUT /api/v1/tenants/{tenant_id}/procurement/internal-transfers/{po_id}/reject- Reject internal transferPOST /api/v1/tenants/{tenant_id}/procurement/internal-transfers/calculate-pricing- Calculate transfer prices
Database Schema
Main Tables
procurement_needs
CREATE TABLE procurement_needs (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
ingredient_id UUID NOT NULL,
ingredient_name VARCHAR(255) NOT NULL,
calculation_date DATE NOT NULL DEFAULT CURRENT_DATE,
current_stock DECIMAL(10, 2) NOT NULL,
projected_consumption DECIMAL(10, 2) NOT NULL, -- Next 7-30 days
safety_stock DECIMAL(10, 2) NOT NULL,
reorder_point DECIMAL(10, 2) NOT NULL,
recommended_order_quantity DECIMAL(10, 2) NOT NULL,
recommended_order_unit VARCHAR(50) NOT NULL,
urgency VARCHAR(50) NOT NULL, -- critical, high, medium, low
estimated_stockout_date DATE,
recommended_supplier_id UUID,
estimated_cost DECIMAL(10, 2),
status VARCHAR(50) DEFAULT 'pending', -- pending, ordered, cancelled
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_needs_tenant_status (tenant_id, status),
INDEX idx_needs_urgency (tenant_id, urgency)
);
purchase_orders
CREATE TABLE purchase_orders (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
po_number VARCHAR(100) NOT NULL, -- Human-readable PO number
supplier_id UUID NOT NULL,
supplier_name VARCHAR(255) NOT NULL, -- Cached for performance
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
expected_delivery_date DATE,
actual_delivery_date DATE,
status VARCHAR(50) DEFAULT 'draft', -- draft, sent, confirmed, in_transit, received, cancelled
payment_terms VARCHAR(100), -- Net 30, Net 60, COD, etc.
payment_status VARCHAR(50) DEFAULT 'unpaid', -- unpaid, paid, overdue
subtotal DECIMAL(10, 2) DEFAULT 0.00,
tax_amount DECIMAL(10, 2) DEFAULT 0.00,
total_amount DECIMAL(10, 2) DEFAULT 0.00,
delivery_address TEXT,
contact_person VARCHAR(255),
contact_phone VARCHAR(50),
contact_email VARCHAR(255),
internal_notes TEXT,
supplier_notes TEXT,
sent_at TIMESTAMP,
confirmed_at TIMESTAMP,
received_at TIMESTAMP,
-- 🆕 Enterprise internal transfer fields (NEW)
is_internal BOOLEAN DEFAULT FALSE NOT NULL, -- TRUE for internal transfers between parent/child
source_tenant_id UUID, -- Parent tenant (source) for internal transfers
destination_tenant_id UUID, -- Child tenant (destination) for internal transfers
transfer_type VARCHAR(50), -- finished_goods, raw_materials
created_by UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, po_number)
);
-- 🆕 NEW indexes for internal transfers
CREATE INDEX idx_po_internal ON purchase_orders(tenant_id, is_internal);
CREATE INDEX idx_po_source_dest ON purchase_orders(source_tenant_id, destination_tenant_id);
CREATE INDEX idx_po_transfer_type ON purchase_orders(is_internal, transfer_type) WHERE is_internal = TRUE;
purchase_order_items
CREATE TABLE purchase_order_items (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
purchase_order_id UUID REFERENCES purchase_orders(id) ON DELETE CASCADE,
ingredient_id UUID NOT NULL,
ingredient_name VARCHAR(255) NOT NULL,
quantity_ordered DECIMAL(10, 2) NOT NULL,
quantity_received DECIMAL(10, 2) DEFAULT 0.00,
unit VARCHAR(50) NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL,
discount_percentage DECIMAL(5, 2) DEFAULT 0.00,
line_total DECIMAL(10, 2) NOT NULL,
tax_rate DECIMAL(5, 2) DEFAULT 0.00,
expected_quality_grade VARCHAR(50),
actual_quality_grade VARCHAR(50),
quality_notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
supplier_products
CREATE TABLE supplier_products (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
supplier_id UUID NOT NULL,
ingredient_id UUID NOT NULL,
supplier_product_code VARCHAR(100),
supplier_product_name VARCHAR(255),
unit_price DECIMAL(10, 2) NOT NULL,
unit VARCHAR(50) NOT NULL,
minimum_order_quantity DECIMAL(10, 2),
lead_time_days INTEGER DEFAULT 3,
is_preferred BOOLEAN DEFAULT FALSE,
quality_grade VARCHAR(50),
valid_from DATE DEFAULT CURRENT_DATE,
valid_until DATE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, supplier_id, ingredient_id)
);
inventory_projections
CREATE TABLE inventory_projections (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
ingredient_id UUID NOT NULL,
ingredient_name VARCHAR(255) NOT NULL,
projection_date DATE NOT NULL,
projected_stock DECIMAL(10, 2) NOT NULL,
projected_consumption DECIMAL(10, 2) NOT NULL,
projected_receipts DECIMAL(10, 2) DEFAULT 0.00,
stockout_risk VARCHAR(50), -- none, low, medium, high, critical
calculated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, ingredient_id, projection_date)
);
reorder_points
CREATE TABLE reorder_points (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
ingredient_id UUID NOT NULL,
ingredient_name VARCHAR(255) NOT NULL,
reorder_point DECIMAL(10, 2) NOT NULL,
safety_stock DECIMAL(10, 2) NOT NULL,
economic_order_quantity DECIMAL(10, 2) NOT NULL,
unit VARCHAR(50) NOT NULL,
average_daily_consumption DECIMAL(10, 2) NOT NULL,
lead_time_days INTEGER NOT NULL,
calculation_method VARCHAR(50), -- manual, auto_basic, auto_advanced
last_calculated_at TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, ingredient_id)
);
procurement_budgets
CREATE TABLE procurement_budgets (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
budget_period VARCHAR(50) NOT NULL, -- monthly, quarterly, annual
period_start DATE NOT NULL,
period_end DATE NOT NULL,
category VARCHAR(100), -- flour, dairy, packaging, etc.
budgeted_amount DECIMAL(10, 2) NOT NULL,
actual_spent DECIMAL(10, 2) DEFAULT 0.00,
variance DECIMAL(10, 2) DEFAULT 0.00,
variance_percentage DECIMAL(5, 2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, period_start, category)
);
Indexes for Performance
CREATE INDEX idx_po_tenant_status ON purchase_orders(tenant_id, status);
CREATE INDEX idx_po_supplier ON purchase_orders(supplier_id);
CREATE INDEX idx_po_expected_delivery ON purchase_orders(tenant_id, expected_delivery_date);
CREATE INDEX idx_po_items_po ON purchase_order_items(purchase_order_id);
CREATE INDEX idx_supplier_products_supplier ON supplier_products(supplier_id);
CREATE INDEX idx_supplier_products_ingredient ON supplier_products(tenant_id, ingredient_id);
CREATE INDEX idx_projections_date ON inventory_projections(tenant_id, projection_date);
Business Logic Examples
Automated Procurement Needs Calculation
async def calculate_procurement_needs(tenant_id: UUID, days_ahead: int = 14) -> list[ProcurementNeed]:
"""
Calculate ingredient procurement needs for next N days.
Accounts for: current stock, production plans, forecasts, lead times, safety stock.
"""
needs = []
# Get all ingredients
ingredients = await get_all_ingredients(tenant_id)
for ingredient in ingredients:
# Get current stock
current_stock = await get_current_stock_level(tenant_id, ingredient.id)
# Get planned consumption from production schedules
planned_consumption = await get_planned_ingredient_consumption(
tenant_id,
ingredient.id,
days_ahead=days_ahead
)
# Get forecasted consumption (if no production plans)
forecast_consumption = await get_forecasted_consumption(
tenant_id,
ingredient.id,
days_ahead=days_ahead
)
# Total projected consumption
projected_consumption = max(planned_consumption, forecast_consumption)
# Get reorder point and safety stock
reorder_config = await get_reorder_point(tenant_id, ingredient.id)
reorder_point = reorder_config.reorder_point
safety_stock = reorder_config.safety_stock
eoq = reorder_config.economic_order_quantity
# Get supplier lead time
supplier = await get_preferred_supplier(tenant_id, ingredient.id)
lead_time_days = supplier.lead_time_days if supplier else 3
# Calculate projected stock at end of period
projected_stock_end = current_stock - projected_consumption
# Determine if order needed
if projected_stock_end < reorder_point:
# Calculate order quantity
shortage = reorder_point - projected_stock_end
order_quantity = max(shortage + safety_stock, eoq)
# Estimate stockout date
daily_consumption = projected_consumption / days_ahead
days_until_stockout = current_stock / daily_consumption if daily_consumption > 0 else 999
stockout_date = date.today() + timedelta(days=int(days_until_stockout))
# Determine urgency
if days_until_stockout <= lead_time_days:
urgency = 'critical'
elif days_until_stockout <= lead_time_days * 1.5:
urgency = 'high'
elif days_until_stockout <= lead_time_days * 2:
urgency = 'medium'
else:
urgency = 'low'
# Get estimated cost
unit_price = await get_ingredient_unit_price(tenant_id, ingredient.id)
estimated_cost = order_quantity * unit_price
# Create procurement need
need = ProcurementNeed(
tenant_id=tenant_id,
ingredient_id=ingredient.id,
ingredient_name=ingredient.name,
current_stock=current_stock,
projected_consumption=projected_consumption,
safety_stock=safety_stock,
reorder_point=reorder_point,
recommended_order_quantity=order_quantity,
recommended_order_unit=ingredient.unit,
urgency=urgency,
estimated_stockout_date=stockout_date,
recommended_supplier_id=supplier.id if supplier else None,
estimated_cost=estimated_cost,
status='pending'
)
db.add(need)
needs.append(need)
await db.commit()
# Publish procurement needs event
if needs:
await publish_event('procurement', 'procurement.needs_calculated', {
'tenant_id': str(tenant_id),
'needs_count': len(needs),
'critical_count': sum(1 for n in needs if n.urgency == 'critical'),
'total_estimated_cost': sum(n.estimated_cost for n in needs)
})
logger.info("Procurement needs calculated",
tenant_id=str(tenant_id),
needs_count=len(needs),
days_ahead=days_ahead)
return needs
Automated Purchase Order Generation
async def generate_purchase_orders(tenant_id: UUID) -> list[PurchaseOrder]:
"""
Generate purchase orders from pending procurement needs.
Groups items by supplier for efficiency.
"""
# Get pending procurement needs
needs = await db.query(ProcurementNeed).filter(
ProcurementNeed.tenant_id == tenant_id,
ProcurementNeed.status == 'pending',
ProcurementNeed.urgency.in_(['critical', 'high'])
).all()
if not needs:
return []
# Group needs by supplier
supplier_groups = {}
for need in needs:
supplier_id = need.recommended_supplier_id
if supplier_id not in supplier_groups:
supplier_groups[supplier_id] = []
supplier_groups[supplier_id].append(need)
# Create purchase order per supplier
purchase_orders = []
for supplier_id, supplier_needs in supplier_groups.items():
# Get supplier details
supplier = await get_supplier(supplier_id)
# Generate PO number
po_number = await generate_po_number(tenant_id)
# Calculate expected delivery date
lead_time = supplier.lead_time_days or 3
expected_delivery = date.today() + timedelta(days=lead_time)
# Create purchase order
po = PurchaseOrder(
tenant_id=tenant_id,
po_number=po_number,
supplier_id=supplier.id,
supplier_name=supplier.name,
order_date=date.today(),
expected_delivery_date=expected_delivery,
status='draft',
payment_terms=supplier.payment_terms or 'Net 30',
delivery_address=await get_default_delivery_address(tenant_id),
contact_person=supplier.contact_name,
contact_phone=supplier.phone,
contact_email=supplier.email,
created_by=tenant_id # System-generated
)
db.add(po)
await db.flush() # Get po.id
# Add items to PO
subtotal = Decimal('0.00')
for need in supplier_needs:
# Get supplier pricing
supplier_product = await get_supplier_product(
supplier_id,
need.ingredient_id
)
unit_price = supplier_product.unit_price
quantity = need.recommended_order_quantity
line_total = unit_price * quantity
po_item = PurchaseOrderItem(
tenant_id=tenant_id,
purchase_order_id=po.id,
ingredient_id=need.ingredient_id,
ingredient_name=need.ingredient_name,
quantity_ordered=quantity,
unit=need.recommended_order_unit,
unit_price=unit_price,
line_total=line_total
)
db.add(po_item)
subtotal += line_total
# Mark need as ordered
need.status = 'ordered'
# Calculate totals (Spanish IVA 10% on food products)
tax_amount = subtotal * Decimal('0.10')
total_amount = subtotal + tax_amount
po.subtotal = subtotal
po.tax_amount = tax_amount
po.total_amount = total_amount
purchase_orders.append(po)
await db.commit()
# Publish event
await publish_event('procurement', 'purchase_orders.generated', {
'tenant_id': str(tenant_id),
'po_count': len(purchase_orders),
'total_value': sum(po.total_amount for po in purchase_orders)
})
logger.info("Purchase orders generated",
tenant_id=str(tenant_id),
count=len(purchase_orders))
return purchase_orders
🆕 Internal Transfer Pricing Calculation (NEW)
async def calculate_transfer_pricing(
tenant_id: UUID,
items: list[dict],
markup_percentage: Optional[float] = None
) -> dict:
"""
Calculate transfer prices for internal purchase orders between parent and child tenants.
Uses recipe cost explosion for locally-produced items, average cost for purchased goods.
⚠️ NOTE: Helper functions _get_recipe_cost() and _get_inventory_average_cost()
are placeholders pending full implementation.
Args:
tenant_id: Parent tenant ID (source of goods)
items: List of items with ingredient_id/recipe_id and quantity
markup_percentage: Optional markup (e.g., 10.0 for 10% markup)
Returns:
Dictionary with item pricing details and totals
"""
from decimal import Decimal
from shared.clients import get_recipe_client, get_inventory_client
recipe_client = get_recipe_client()
inventory_client = get_inventory_client()
pricing_details = []
subtotal = Decimal('0.00')
for item in items:
item_type = item.get('item_type') # 'finished_good' or 'raw_material'
item_id = item.get('item_id')
quantity = Decimal(str(item.get('quantity', 0)))
unit = item.get('unit', 'kg')
if item_type == 'finished_good':
# Recipe-based costing (cost explosion)
# ⚠️ This is a placeholder - actual implementation pending
recipe = await recipe_client.get_recipe(tenant_id, item_id)
# Calculate total ingredient cost for the recipe
ingredient_cost = Decimal('0.00')
for ingredient in recipe.get('ingredients', []):
ingredient_id = ingredient['ingredient_id']
ingredient_qty = Decimal(str(ingredient['quantity']))
# Get current average cost from inventory
avg_cost = await _get_inventory_average_cost(
tenant_id,
ingredient_id
)
ingredient_cost += avg_cost * ingredient_qty
# Add production overhead (estimated 20% of material cost)
production_overhead = ingredient_cost * Decimal('0.20')
base_cost = ingredient_cost + production_overhead
# Calculate cost per unit (recipe yield)
recipe_yield = Decimal(str(recipe.get('yield_quantity', 1)))
unit_cost = base_cost / recipe_yield if recipe_yield > 0 else Decimal('0.00')
else: # raw_material
# Use average inventory cost
# ⚠️ This is a placeholder - actual implementation pending
unit_cost = await _get_inventory_average_cost(tenant_id, item_id)
# Apply markup if specified
if markup_percentage:
markup_multiplier = Decimal('1.0') + (Decimal(str(markup_percentage)) / Decimal('100'))
unit_price = unit_cost * markup_multiplier
else:
unit_price = unit_cost
# Calculate line total
line_total = unit_price * quantity
subtotal += line_total
pricing_details.append({
'item_id': item_id,
'item_type': item_type,
'item_name': item.get('item_name'),
'quantity': float(quantity),
'unit': unit,
'base_cost': float(unit_cost),
'unit_price': float(unit_price),
'line_total': float(line_total),
'markup_applied': markup_percentage is not None
})
# Calculate tax (Spanish IVA 10% on food products)
tax_rate = Decimal('0.10')
tax_amount = subtotal * tax_rate
total_amount = subtotal + tax_amount
result = {
'tenant_id': str(tenant_id),
'items': pricing_details,
'subtotal': float(subtotal),
'tax_amount': float(tax_amount),
'total_amount': float(total_amount),
'markup_percentage': markup_percentage,
'pricing_method': 'cost_based'
}
logger.info("Transfer pricing calculated",
tenant_id=str(tenant_id),
item_count=len(items),
total_amount=float(total_amount))
return result
# ⚠️ PLACEHOLDER HELPER FUNCTIONS - Full implementation pending
async def _get_recipe_cost(tenant_id: UUID, recipe_id: UUID) -> Decimal:
"""
Calculate total cost for a recipe by exploding ingredient costs.
⚠️ This is a placeholder - needs integration with Recipe Service.
"""
# TODO: Implement full recipe cost explosion
# 1. Fetch recipe with all ingredients
# 2. Get current inventory average cost for each ingredient
# 3. Calculate total ingredient cost
# 4. Add production overhead
return Decimal('0.00')
async def _get_inventory_average_cost(tenant_id: UUID, ingredient_id: UUID) -> Decimal:
"""
Get average cost per unit from inventory service.
⚠️ This is a placeholder - needs integration with Inventory Service.
"""
# TODO: Implement inventory average cost lookup
# Uses weighted average cost from recent stock receipts
return Decimal('0.00')
Economic Order Quantity (EOQ) Calculation
def calculate_eoq(
annual_demand: float,
ordering_cost_per_order: float,
holding_cost_per_unit_per_year: float
) -> float:
"""
Calculate Economic Order Quantity using Wilson's formula.
EOQ = sqrt((2 * D * S) / H)
Where:
- D = Annual demand
- S = Ordering cost per order
- H = Holding cost per unit per year
"""
if holding_cost_per_unit_per_year == 0:
return 0
eoq = math.sqrt(
(2 * annual_demand * ordering_cost_per_order) / holding_cost_per_unit_per_year
)
return round(eoq, 2)
async def calculate_reorder_point(
tenant_id: UUID,
ingredient_id: UUID
) -> ReorderPoint:
"""
Calculate reorder point and safety stock for ingredient.
Reorder Point = (Average Daily Consumption × Lead Time) + Safety Stock
Safety Stock = Z-score × σ × sqrt(Lead Time)
"""
# Get historical consumption data (last 90 days)
consumption_history = await get_consumption_history(
tenant_id,
ingredient_id,
days=90
)
# Calculate average daily consumption
if len(consumption_history) > 0:
avg_daily_consumption = sum(consumption_history) / len(consumption_history)
std_dev_consumption = statistics.stdev(consumption_history) if len(consumption_history) > 1 else 0
else:
avg_daily_consumption = 0
std_dev_consumption = 0
# Get supplier lead time
supplier = await get_preferred_supplier(tenant_id, ingredient_id)
lead_time_days = supplier.lead_time_days if supplier else 3
# Calculate safety stock (95% service level, Z=1.65)
z_score = 1.65
safety_stock = z_score * std_dev_consumption * math.sqrt(lead_time_days)
# Calculate reorder point
reorder_point = (avg_daily_consumption * lead_time_days) + safety_stock
# Calculate EOQ
annual_demand = avg_daily_consumption * 365
ordering_cost = 25.0 # Estimated administrative cost per order
unit_price = await get_ingredient_unit_price(tenant_id, ingredient_id)
holding_cost_rate = 0.20 # 20% of unit cost per year
holding_cost = unit_price * holding_cost_rate
eoq = calculate_eoq(annual_demand, ordering_cost, holding_cost)
# Store reorder point configuration
reorder_config = await db.get(ReorderPoint, {'tenant_id': tenant_id, 'ingredient_id': ingredient_id})
if not reorder_config:
reorder_config = ReorderPoint(
tenant_id=tenant_id,
ingredient_id=ingredient_id,
ingredient_name=await get_ingredient_name(ingredient_id)
)
db.add(reorder_config)
reorder_config.reorder_point = round(reorder_point, 2)
reorder_config.safety_stock = round(safety_stock, 2)
reorder_config.economic_order_quantity = round(eoq, 2)
reorder_config.average_daily_consumption = round(avg_daily_consumption, 2)
reorder_config.lead_time_days = lead_time_days
reorder_config.calculation_method = 'auto_advanced'
reorder_config.last_calculated_at = datetime.utcnow()
await db.commit()
return reorder_config
Supplier Recommendation Engine
async def recommend_supplier(
tenant_id: UUID,
ingredient_id: UUID,
quantity: float
) -> UUID:
"""
Recommend best supplier based on price, quality, and delivery performance.
Scoring: 40% price, 30% quality, 30% delivery
"""
# Get all suppliers for ingredient
suppliers = await db.query(SupplierProduct).filter(
SupplierProduct.tenant_id == tenant_id,
SupplierProduct.ingredient_id == ingredient_id,
SupplierProduct.is_active == True
).all()
if not suppliers:
return None
supplier_scores = []
for supplier_product in suppliers:
# Get supplier performance metrics
supplier = await get_supplier(supplier_product.supplier_id)
performance = await get_supplier_performance(supplier.id)
# Price score (lower is better, normalized 0-100)
prices = [sp.unit_price for sp in suppliers]
min_price = min(prices)
max_price = max(prices)
if max_price > min_price:
price_score = 100 * (1 - (supplier_product.unit_price - min_price) / (max_price - min_price))
else:
price_score = 100
# Quality score (0-100, from supplier ratings)
quality_score = supplier.quality_rating or 75
# Delivery score (0-100, based on on-time delivery %)
delivery_score = performance.on_time_delivery_percentage if performance else 80
# Weighted total score
total_score = (
price_score * 0.40 +
quality_score * 0.30 +
delivery_score * 0.30
)
supplier_scores.append({
'supplier_id': supplier.id,
'supplier_name': supplier.name,
'total_score': total_score,
'price_score': price_score,
'quality_score': quality_score,
'delivery_score': delivery_score,
'unit_price': supplier_product.unit_price
})
# Sort by total score descending
supplier_scores.sort(key=lambda x: x['total_score'], reverse=True)
# Return best supplier
return supplier_scores[0]['supplier_id'] if supplier_scores else None
Events & Messaging
Published Events (RabbitMQ)
Exchange: procurement.events
Routing Keys: po.approved, po.rejected, po.sent_to_supplier, delivery.received, procurement.needs_calculated, procurement.po_created, procurement.stockout_risk
Purchase Order Lifecycle Events (🆕)
PO Approved Event - Published when a PO is approved
- Routing Key:
po.approved - Consumed By: Notification service (sends email to supplier)
- Trigger: User approves PO in dashboard or via API
{
"event_id": "uuid",
"event_type": "po.approved",
"service_name": "procurement",
"timestamp": "2025-11-12T10:30:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "PO-2025-1112-001",
"supplier_id": "uuid",
"supplier_name": "Harinas García",
"supplier_email": "pedidos@harinasgarcia.es",
"supplier_phone": "+34612345678",
"total_amount": 850.00,
"currency": "EUR",
"required_delivery_date": "2025-11-19",
"items": [
{
"product_name": "Harina de Trigo T-55",
"ordered_quantity": 100.0,
"unit_of_measure": "kg",
"unit_price": 0.85,
"line_total": 85.00
}
],
"approved_by": "uuid",
"approved_at": "2025-11-12T10:30:00Z"
}
}
PO Rejected Event - Published when a PO is rejected
- Routing Key:
po.rejected - Consumed By: Notification service (notifies stakeholders)
- Trigger: User rejects PO with reason
{
"event_id": "uuid",
"event_type": "po.rejected",
"service_name": "procurement",
"timestamp": "2025-11-12T10:30:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "PO-2025-1112-001",
"supplier_id": "uuid",
"supplier_name": "Harinas García",
"rejection_reason": "Price too high compared to market rate",
"rejected_by": "uuid",
"rejected_at": "2025-11-12T10:30:00Z"
}
}
Delivery Received Event - Published when a delivery is recorded
- Routing Key:
delivery.received - Consumed By: Inventory service (automatically updates stock)
- Trigger: User records delivery receipt in
DeliveryReceiptModal
{
"event_id": "uuid",
"event_type": "delivery.received",
"service_name": "procurement",
"timestamp": "2025-11-12T10:30:00Z",
"data": {
"tenant_id": "uuid",
"delivery_id": "uuid",
"po_id": "uuid",
"received_by": "uuid",
"received_at": "2025-11-12T10:30:00Z",
"items": [
{
"inventory_product_id": "uuid",
"product_name": "Harina de Trigo T-55",
"ordered_quantity": 100.0,
"delivered_quantity": 98.0,
"accepted_quantity": 95.0,
"rejected_quantity": 3.0,
"batch_lot_number": "LOT-2025-1112",
"expiry_date": "2025-12-12",
"rejection_reason": "3 damaged bags"
}
]
}
}
Legacy/Other Events
Exchange: procurement
Routing Keys: procurement.needs_calculated, procurement.po_created, procurement.stockout_risk
Procurement Needs Calculated Event
{
"event_type": "procurement_needs_calculated",
"tenant_id": "uuid",
"needs_count": 12,
"critical_count": 3,
"high_count": 5,
"total_estimated_cost": 2450.00,
"critical_ingredients": [
{"ingredient_id": "uuid", "ingredient_name": "Harina de Trigo", "days_until_stockout": 2},
{"ingredient_id": "uuid", "ingredient_name": "Levadura", "days_until_stockout": 3}
],
"timestamp": "2025-11-06T08:00:00Z"
}
Purchase Order Created Event
{
"event_type": "purchase_order_created",
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "PO-2025-1106-001",
"supplier_id": "uuid",
"supplier_name": "Harinas García",
"total_amount": 850.00,
"item_count": 5,
"expected_delivery_date": "2025-11-10",
"status": "sent",
"timestamp": "2025-11-06T10:30:00Z"
}
Purchase Order Received Event
{
"event_type": "purchase_order_received",
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "PO-2025-1106-001",
"supplier_id": "uuid",
"expected_delivery_date": "2025-11-10",
"actual_delivery_date": "2025-11-09",
"on_time": true,
"quality_issues": false,
"timestamp": "2025-11-09T07:30:00Z"
}
Stockout Risk Alert
{
"event_type": "stockout_risk",
"tenant_id": "uuid",
"ingredient_id": "uuid",
"ingredient_name": "Mantequilla",
"current_stock": 5.5,
"unit": "kg",
"projected_consumption_7days": 12.0,
"days_until_stockout": 3,
"risk_level": "high",
"recommended_action": "Place order immediately",
"timestamp": "2025-11-06T09:00:00Z"
}
🆕 Enterprise Internal Transfer Events (NEW)
Internal Transfer Created Event - Published when an internal PO is created between parent and child
- Routing Key:
internal_transfer.created - Consumed By: Distribution service (for delivery route planning)
- Trigger: Child tenant creates internal transfer PO
{
"event_id": "uuid",
"event_type": "internal_transfer.created",
"service_name": "procurement",
"timestamp": "2025-11-12T10:30:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "INT-2025-1112-001",
"parent_tenant_id": "uuid",
"child_tenant_id": "uuid",
"transfer_type": "finished_goods",
"status": "pending_approval",
"items": [
{
"item_type": "finished_good",
"recipe_id": "uuid",
"product_name": "Pan de Molde",
"quantity": 50.0,
"unit": "kg",
"transfer_price": 2.50,
"line_total": 125.00
}
],
"subtotal": 125.00,
"tax_amount": 12.50,
"total_amount": 137.50,
"requested_delivery_date": "2025-11-14",
"created_by": "uuid"
}
}
Internal Transfer Approved Event - Published when parent approves internal transfer
- Routing Key:
internal_transfer.approved - Consumed By: Distribution service (creates shipment), Inventory service (reserves stock)
- Trigger: Parent tenant approves internal transfer request
{
"event_id": "uuid",
"event_type": "internal_transfer.approved",
"service_name": "procurement",
"timestamp": "2025-11-12T14:00:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "INT-2025-1112-001",
"parent_tenant_id": "uuid",
"child_tenant_id": "uuid",
"total_amount": 137.50,
"requested_delivery_date": "2025-11-14",
"approved_by": "uuid",
"approved_at": "2025-11-12T14:00:00Z",
"items": [
{
"item_id": "uuid",
"quantity": 50.0,
"unit": "kg"
}
]
}
}
Internal Transfer Rejected Event - Published when parent rejects internal transfer
- Routing Key:
internal_transfer.rejected - Consumed By: Notification service (notifies child tenant)
- Trigger: Parent tenant rejects internal transfer request
{
"event_id": "uuid",
"event_type": "internal_transfer.rejected",
"service_name": "procurement",
"timestamp": "2025-11-12T14:00:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "INT-2025-1112-001",
"parent_tenant_id": "uuid",
"child_tenant_id": "uuid",
"rejection_reason": "Insufficient production capacity for requested date",
"rejected_by": "uuid",
"rejected_at": "2025-11-12T14:00:00Z"
}
}
Consumed Events
- From Production: Production schedules trigger procurement needs calculation
- From Forecasting: Demand forecasts inform procurement planning
- From Inventory: Stock level changes update projections
- From Orchestrator: Daily procurement planning trigger
- 🆕 From Distribution (NEW): Shipment delivery completed → Update internal PO status to 'delivered'
- 🆕 From Tenant (NEW): Child outlet created → Setup default procurement settings for new location
Custom Metrics (Prometheus)
# Procurement metrics
procurement_needs_total = Counter(
'procurement_needs_total',
'Total procurement needs identified',
['tenant_id', 'urgency']
)
purchase_orders_total = Counter(
'purchase_orders_total',
'Total purchase orders created',
['tenant_id', 'supplier_id', 'status']
)
purchase_order_value_euros = Histogram(
'purchase_order_value_euros',
'Purchase order value distribution',
['tenant_id'],
buckets=[100, 250, 500, 1000, 2000, 5000, 10000]
)
# Supplier performance metrics
supplier_delivery_time_days = Histogram(
'supplier_delivery_time_days',
'Supplier delivery time',
['tenant_id', 'supplier_id'],
buckets=[1, 2, 3, 5, 7, 10, 14, 21]
)
supplier_on_time_delivery = Gauge(
'supplier_on_time_delivery_percentage',
'Supplier on-time delivery rate',
['tenant_id', 'supplier_id']
)
# Stock optimization metrics
stockout_events_total = Counter(
'stockout_events_total',
'Total stockout events',
['tenant_id', 'ingredient_id']
)
inventory_turnover_ratio = Gauge(
'inventory_turnover_ratio',
'Inventory turnover ratio',
['tenant_id', 'category']
)
Configuration
Environment Variables
Service Configuration:
PORT- Service port (default: 8011)DATABASE_URL- PostgreSQL connection stringREDIS_URL- Redis connection stringRABBITMQ_URL- RabbitMQ connection string
Procurement Configuration:
DEFAULT_LEAD_TIME_DAYS- Default supplier lead time (default: 3)SAFETY_STOCK_SERVICE_LEVEL- Z-score for safety stock (default: 1.65 for 95%)PROJECTION_DAYS_AHEAD- Days to project inventory (default: 14)ENABLE_AUTO_PO_GENERATION- Auto-create POs (default: false)AUTO_PO_MIN_VALUE- Minimum PO value for auto-creation (default: 100.00)
Cost Configuration:
DEFAULT_ORDERING_COST- Administrative cost per order (default: 25.00)DEFAULT_HOLDING_COST_RATE- Annual holding cost rate (default: 0.20)ENABLE_BUDGET_ALERTS- Alert on budget variance (default: true)BUDGET_VARIANCE_THRESHOLD- Alert threshold percentage (default: 10.0)
Supplier Configuration:
PRICE_WEIGHT- Supplier scoring weight for price (default: 0.40)QUALITY_WEIGHT- Supplier scoring weight for quality (default: 0.30)DELIVERY_WEIGHT- Supplier scoring weight for delivery (default: 0.30)
Development Setup
Prerequisites
- Python 3.11+
- PostgreSQL 17
- Redis 7.4
- RabbitMQ 4.1
Local Development
cd services/procurement
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL=postgresql://user:pass@localhost:5432/procurement
export REDIS_URL=redis://localhost:6379/0
export RABBITMQ_URL=amqp://guest:guest@localhost:5672/
alembic upgrade head
python main.py
Integration Points
Dependencies
- Production Service - Production schedules for consumption projection
- Inventory Service - Current stock levels
- Forecasting Service - Demand forecasts for planning
- Recipes Service - Ingredient requirements per recipe
- Suppliers Service - Supplier data and pricing
- Auth Service - User authentication
- 🆕 Tenant Service (NEW) - Tenant hierarchy for internal transfers (parent/child relationships)
- 🆕 Distribution Service (NEW) - Delivery route planning for approved internal transfers
- PostgreSQL - Procurement data
- Redis - Calculation caching
- RabbitMQ - Event publishing
Dependents
- Inventory Service - Purchase orders create inventory receipts
- Accounting Service - Purchase orders for expense tracking
- Notification Service - Stockout and PO alerts
- AI Insights Service - Procurement optimization recommendations
- Frontend Dashboard - Procurement management UI
- 🆕 Distribution Service (NEW) - Internal transfer POs feed into delivery route optimization
- 🆕 Forecasting Service (NEW) - Transfer pricing data informs cost predictions
Business Value for VUE Madrid
Problem Statement
Spanish bakeries struggle with:
- Manual ordering leading to stockouts or overstock
- No visibility into future ingredient needs
- Reactive procurement (order when empty, too late)
- No systematic supplier performance tracking
- Manual price comparison across suppliers
- Excess inventory tying up cash
Solution
Bakery-IA Procurement Service provides:
- Automated Planning: System calculates what and when to order
- Stockout Prevention: 85-95% reduction in production delays
- Cost Optimization: Supplier recommendations based on data
- Inventory Optimization: 20-30% less inventory with same service
- Supplier Management: Performance tracking and leverage
Quantifiable Impact
Cost Savings:
- €200-400/month from optimized ordering (5-15% procurement savings)
- €100-300/month from reduced excess inventory
- €150-500/month from stockout prevention (lost production)
- Total: €450-1,200/month savings
Time Savings:
- 8-12 hours/week on manual ordering and tracking
- 2-3 hours/week on supplier communication
- 1-2 hours/week on inventory checks
- Total: 11-17 hours/week saved
Operational Improvements:
- 85-95% stockout prevention rate
- 20-30% inventory reduction
- 15-25% supplier delivery improvement
- 10-20% less spoilage from overstock
Target Market Fit (Spanish Bakeries)
- Cash Flow Sensitive: Spanish SMBs need optimal inventory investment
- Supplier Relationships: Data enables better supplier negotiations
- Regulatory: Proper PO documentation for Spanish tax compliance
- Growth: Automation enables scaling without procurement staff
ROI Calculation
Investment: €0 additional (included in platform subscription) Monthly Savings: €450-1,200 Annual ROI: €5,400-14,400 value per bakery Payback: Immediate (included in subscription)
Copyright © 2025 Bakery-IA. All rights reserved.