Files
bakery-ia/services/procurement
Claude f74b8d5402 refactor: Remove TEXT fields and use only reasoning_data for i18n
Completed the migration to structured reasoning_data for multilingual
dashboard support. Removed hardcoded TEXT fields (reasoning, consequence)
and updated all related code to use JSONB reasoning_data.

Changes:

1. Models Updated (removed TEXT fields):
   - PurchaseOrder: Removed reasoning, consequence TEXT columns
   - ProductionBatch: Removed reasoning TEXT column
   - Both now use only reasoning_data (JSONB/JSON)

2. Dashboard Service Updated:
   - Changed to return reasoning_data instead of TEXT fields
   - Creates default reasoning_data if missing
   - PO actions: reasoning_data with type and parameters
   - Production timeline: reasoning_data for each batch

3. Unified Schemas Updated (no separate migration):
   - services/procurement/migrations/001_unified_initial_schema.py
   - services/production/migrations/001_unified_initial_schema.py
   - Removed reasoning/consequence columns from table definitions
   - Updated comments to reflect i18n approach

Database Schema:
- purchase_orders: Only reasoning_data (JSONB)
- production_batches: Only reasoning_data (JSON)

Backend now generates:
{
  "type": "low_stock_detection",
  "parameters": {
    "supplier_name": "Harinas del Norte",
    "days_until_stockout": 3,
    ...
  },
  "consequence": {
    "type": "stockout_risk",
    "severity": "high"
  }
}

Next Steps:
- Frontend: Create i18n translation keys
- Frontend: Update components to translate reasoning_data
- Test multilingual support (ES, EN, CA)
2025-11-07 18:20:05 +00:00
..
2025-11-05 13:34:56 +01:00
2025-10-30 21:08:07 +01:00
2025-10-30 21:08:07 +01:00
2025-11-06 14:10:04 +01:00
2025-11-05 13:34:56 +01:00

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

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}/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

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

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 Routing Keys: procurement.needs_calculated, procurement.po_created, procurement.po_received, 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 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

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.