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
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
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,
created_by UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, po_number)
);
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
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"
}
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
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
- 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
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.