# 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 needs - `POST /api/v1/procurement/needs/calculate` - Trigger needs calculation - `GET /api/v1/procurement/needs/{need_id}` - Get procurement need details - `GET /api/v1/procurement/projections` - Get inventory projections ### Purchase Orders - `GET /api/v1/procurement/purchase-orders` - List purchase orders - `POST /api/v1/procurement/purchase-orders` - Create purchase order - `GET /api/v1/procurement/purchase-orders/{po_id}` - Get PO details - `PUT /api/v1/procurement/purchase-orders/{po_id}` - Update PO - `POST /api/v1/procurement/purchase-orders/{po_id}/approve` - Approve PO (triggers email) - `POST /api/v1/procurement/purchase-orders/{po_id}/reject` - Reject PO with reason - `POST /api/v1/procurement/purchase-orders/{po_id}/send` - Send PO to supplier - `POST /api/v1/procurement/purchase-orders/{po_id}/receive` - Mark PO received - `POST /api/v1/procurement/purchase-orders/{po_id}/cancel` - Cancel PO - `GET /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 receipt - `GET /api/v1/procurement/purchase-orders/{po_id}/deliveries` - List deliveries for PO - `GET /api/v1/procurement/purchase-orders/{po_id}/deliveries/{delivery_id}` - Get delivery details - `PATCH /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 items - `POST /api/v1/procurement/purchase-orders/{po_id}/items` - Add item to PO - `PUT /api/v1/procurement/purchase-orders/{po_id}/items/{item_id}` - Update item - `DELETE /api/v1/procurement/purchase-orders/{po_id}/items/{item_id}` - Remove item ### Supplier Management - `GET /api/v1/procurement/suppliers/{supplier_id}/products` - Supplier product catalog - `GET /api/v1/procurement/suppliers/{supplier_id}/pricing` - Get supplier pricing - `POST /api/v1/procurement/suppliers/{supplier_id}/pricing` - Update pricing - `GET /api/v1/procurement/suppliers/recommend` - Get supplier recommendations ### Analytics - `GET /api/v1/procurement/analytics/dashboard` - Procurement dashboard - `GET /api/v1/procurement/analytics/spend` - Spending analysis - `GET /api/v1/procurement/analytics/supplier-performance` - Supplier metrics - `GET /api/v1/procurement/analytics/stockouts` - Stockout analysis - `GET /api/v1/procurement/analytics/lead-times` - Lead time analysis ## Database Schema ### Main Tables **procurement_needs** ```sql 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** ```sql 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** ```sql 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** ```sql 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** ```sql 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** ```sql 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** ```sql 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 ```sql 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 ```python 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 ```python 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 ```python 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 ```python 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 ```json { "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 ```json { "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` ```json { "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** ```json { "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** ```json { "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** ```json { "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** ```json { "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) ```python # 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 string - `REDIS_URL` - Redis connection string - `RABBITMQ_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 ```bash 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.**