Files
bakery-ia/services/procurement
2026-01-12 22:15:11 +01:00
..
2026-01-12 22:15:11 +01:00
2025-11-30 09:12:40 +01: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-30 09:12:40 +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

Purchase Order Workflow & Tracking (🆕)

  • Dashboard-Integrated Approval - Approve/reject POs directly from dashboard action queue
  • Progressive Disclosure - View PO details inline without navigation
  • Automatic Supplier Notifications - Professional HTML emails sent on approval
  • Delivery Date Tracking - Auto-calculate estimated delivery based on supplier lead time
  • Color-Coded Status Indicators - Visual delivery tracking (green/yellow/red)
  • Overdue Detection - Hourly background job detects late deliveries
  • Severity Levels - Low (1d), Medium (2-3d), High (4-7d), Critical (7+ days overdue)
  • Delivery Receipt Modal - Comprehensive delivery recording with batch/expiry tracking
  • Automatic Stock Updates - Inventory updated automatically via events when deliveries recorded

Stock Optimization

  • Reorder Point Calculation - When to order based on consumption rate
  • Economic Order Quantity (EOQ) - Optimal order size calculation
  • ABC Analysis - Prioritize critical ingredients
  • Stockout Prevention - 85-95% stockout prevention rate
  • Overstock Alerts - Warn against excessive inventory
  • Seasonal Adjustment - Adjust for seasonal demand patterns

Cost Management

  • Price Tracking - Monitor ingredient price trends over time
  • Budget Management - Track spending against procurement budgets
  • Cost Variance Analysis - Compare planned vs. actual costs
  • Volume Discounts - Automatic discount application
  • Contract Pricing - Manage fixed-price contracts with suppliers
  • Cost Savings Reports - Quantify procurement optimization savings

Analytics & Reporting

  • Procurement Dashboard - Real-time procurement KPIs
  • Spend Analysis - Category and supplier spending breakdown
  • Lead Time Analytics - Average delivery times per supplier
  • Stockout Reports - Track missed orders due to stockouts
  • Supplier Performance - On-time delivery and quality metrics
  • ROI Tracking - Measure procurement efficiency gains

🆕 Enterprise Tier: Internal Transfers (NEW)

  • Parent-Child Transfer Orders - Create internal purchase orders between central production and retail outlets
  • Cost-Based Transfer Pricing - Calculate transfer prices based on actual production costs
  • Recipe Cost Explosion - Automatic cost calculation from recipe ingredients for locally-produced items
  • Average Cost Fallback - Use inventory average cost for purchased goods
  • Markup Configuration - Optional markup on transfer prices (default 0%, configurable per tenant)
  • Approval Workflow - Parent bakery must approve all internal transfer requests from children
  • Integration with Distribution - Approved internal POs feed into delivery route optimization
  • Inventory Coordination - Automatic inventory transfer on delivery completion via events
  • Transfer Type Tracking - Distinguish between finished_goods and raw_materials transfers
  • Enterprise Subscription Gating - Internal transfers require Enterprise tier subscription

Business Value

For Bakery Owners

  • Stockout Prevention - Never miss production due to missing ingredients
  • Cost Optimization - 5-15% procurement cost savings through automation
  • Cash Flow Management - Optimize inventory investment
  • Supplier Leverage - Data-driven supplier negotiations
  • Time Savings - Automated ordering vs. manual tracking
  • Compliance - Proper purchase order documentation for accounting

Quantifiable Impact

  • Stockout Prevention: 85-95% reduction in production delays
  • Cost Savings: 5-15% through optimized ordering and price comparison
  • Time Savings: 8-12 hours/week on manual ordering and tracking
  • Inventory Reduction: 20-30% lower inventory levels with same service
  • Supplier Performance: 15-25% improvement in on-time delivery
  • Waste Reduction: 10-20% less spoilage from excess inventory

For Procurement Staff

  • Automated Calculations - System calculates what and when to order
  • Supplier Insights - Best supplier recommendations with data
  • Order Tracking - Visibility into all pending orders
  • Exception Management - Focus on issues, not routine orders
  • Performance Metrics - Clear KPIs for procurement efficiency

Technology Stack

  • Framework: FastAPI (Python 3.11+) - Async web framework
  • Database: PostgreSQL 17 - Procurement data
  • Caching: Redis 7.4 - Calculation results cache
  • Messaging: RabbitMQ 4.1 - Event publishing
  • ORM: SQLAlchemy 2.0 (async) - Database abstraction
  • Validation: Pydantic 2.0 - Schema validation
  • Logging: Structlog - Structured JSON logging
  • Metrics: Prometheus Client - Procurement metrics

API Endpoints (Key Routes)

Procurement Planning

  • GET /api/v1/procurement/needs - Calculate current procurement 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

🆕 Enterprise Internal Transfers (NEW)

  • POST /api/v1/tenants/{tenant_id}/procurement/internal-transfers - Create internal transfer PO
  • GET /api/v1/tenants/{tenant_id}/procurement/internal-transfers - List all internal transfers
  • GET /api/v1/tenants/{tenant_id}/procurement/internal-transfers/pending - Get pending approvals
  • GET /api/v1/tenants/{tenant_id}/procurement/internal-transfers/history - Get transfer history
  • PUT /api/v1/tenants/{tenant_id}/procurement/internal-transfers/{po_id}/approve - Approve internal transfer (parent only)
  • PUT /api/v1/tenants/{tenant_id}/procurement/internal-transfers/{po_id}/reject - Reject internal transfer
  • POST /api/v1/tenants/{tenant_id}/procurement/internal-transfers/calculate-pricing - Calculate transfer prices

Database Schema

Main Tables

procurement_needs

CREATE TABLE procurement_needs (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    ingredient_id UUID NOT NULL,
    ingredient_name VARCHAR(255) NOT NULL,
    calculation_date DATE NOT NULL DEFAULT CURRENT_DATE,
    current_stock DECIMAL(10, 2) NOT NULL,
    projected_consumption DECIMAL(10, 2) NOT NULL,  -- Next 7-30 days
    safety_stock DECIMAL(10, 2) NOT NULL,
    reorder_point DECIMAL(10, 2) NOT NULL,
    recommended_order_quantity DECIMAL(10, 2) NOT NULL,
    recommended_order_unit VARCHAR(50) NOT NULL,
    urgency VARCHAR(50) NOT NULL,                    -- critical, high, medium, low
    estimated_stockout_date DATE,
    recommended_supplier_id UUID,
    estimated_cost DECIMAL(10, 2),
    status VARCHAR(50) DEFAULT 'pending',            -- pending, ordered, cancelled
    notes TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    INDEX idx_needs_tenant_status (tenant_id, status),
    INDEX idx_needs_urgency (tenant_id, urgency)
);

purchase_orders

CREATE TABLE purchase_orders (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    po_number VARCHAR(100) NOT NULL,                 -- Human-readable PO number
    supplier_id UUID NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,             -- Cached for performance
    order_date DATE NOT NULL DEFAULT CURRENT_DATE,
    expected_delivery_date DATE,
    actual_delivery_date DATE,
    status VARCHAR(50) DEFAULT 'draft',              -- draft, sent, confirmed, in_transit, received, cancelled
    payment_terms VARCHAR(100),                      -- Net 30, Net 60, COD, etc.
    payment_status VARCHAR(50) DEFAULT 'unpaid',     -- unpaid, paid, overdue
    subtotal DECIMAL(10, 2) DEFAULT 0.00,
    tax_amount DECIMAL(10, 2) DEFAULT 0.00,
    total_amount DECIMAL(10, 2) DEFAULT 0.00,
    delivery_address TEXT,
    contact_person VARCHAR(255),
    contact_phone VARCHAR(50),
    contact_email VARCHAR(255),
    internal_notes TEXT,
    supplier_notes TEXT,
    sent_at TIMESTAMP,
    confirmed_at TIMESTAMP,
    received_at TIMESTAMP,

    -- 🆕 Enterprise internal transfer fields (NEW)
    is_internal BOOLEAN DEFAULT FALSE NOT NULL,      -- TRUE for internal transfers between parent/child
    source_tenant_id UUID,                           -- Parent tenant (source) for internal transfers
    destination_tenant_id UUID,                      -- Child tenant (destination) for internal transfers
    transfer_type VARCHAR(50),                       -- finished_goods, raw_materials

    created_by UUID NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(tenant_id, po_number)
);

-- 🆕 NEW indexes for internal transfers
CREATE INDEX idx_po_internal ON purchase_orders(tenant_id, is_internal);
CREATE INDEX idx_po_source_dest ON purchase_orders(source_tenant_id, destination_tenant_id);
CREATE INDEX idx_po_transfer_type ON purchase_orders(is_internal, transfer_type) WHERE is_internal = TRUE;

purchase_order_items

CREATE TABLE purchase_order_items (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    purchase_order_id UUID REFERENCES purchase_orders(id) ON DELETE CASCADE,
    ingredient_id UUID NOT NULL,
    ingredient_name VARCHAR(255) NOT NULL,
    quantity_ordered DECIMAL(10, 2) NOT NULL,
    quantity_received DECIMAL(10, 2) DEFAULT 0.00,
    unit VARCHAR(50) NOT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    discount_percentage DECIMAL(5, 2) DEFAULT 0.00,
    line_total DECIMAL(10, 2) NOT NULL,
    tax_rate DECIMAL(5, 2) DEFAULT 0.00,
    expected_quality_grade VARCHAR(50),
    actual_quality_grade VARCHAR(50),
    quality_notes TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

supplier_products

CREATE TABLE supplier_products (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    supplier_id UUID NOT NULL,
    ingredient_id UUID NOT NULL,
    supplier_product_code VARCHAR(100),
    supplier_product_name VARCHAR(255),
    unit_price DECIMAL(10, 2) NOT NULL,
    unit VARCHAR(50) NOT NULL,
    minimum_order_quantity DECIMAL(10, 2),
    lead_time_days INTEGER DEFAULT 3,
    is_preferred BOOLEAN DEFAULT FALSE,
    quality_grade VARCHAR(50),
    valid_from DATE DEFAULT CURRENT_DATE,
    valid_until DATE,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(tenant_id, supplier_id, ingredient_id)
);

inventory_projections

CREATE TABLE inventory_projections (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    ingredient_id UUID NOT NULL,
    ingredient_name VARCHAR(255) NOT NULL,
    projection_date DATE NOT NULL,
    projected_stock DECIMAL(10, 2) NOT NULL,
    projected_consumption DECIMAL(10, 2) NOT NULL,
    projected_receipts DECIMAL(10, 2) DEFAULT 0.00,
    stockout_risk VARCHAR(50),                       -- none, low, medium, high, critical
    calculated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(tenant_id, ingredient_id, projection_date)
);

reorder_points

CREATE TABLE reorder_points (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    ingredient_id UUID NOT NULL,
    ingredient_name VARCHAR(255) NOT NULL,
    reorder_point DECIMAL(10, 2) NOT NULL,
    safety_stock DECIMAL(10, 2) NOT NULL,
    economic_order_quantity DECIMAL(10, 2) NOT NULL,
    unit VARCHAR(50) NOT NULL,
    average_daily_consumption DECIMAL(10, 2) NOT NULL,
    lead_time_days INTEGER NOT NULL,
    calculation_method VARCHAR(50),                  -- manual, auto_basic, auto_advanced
    last_calculated_at TIMESTAMP DEFAULT NOW(),
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(tenant_id, ingredient_id)
);

procurement_budgets

CREATE TABLE procurement_budgets (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    budget_period VARCHAR(50) NOT NULL,              -- monthly, quarterly, annual
    period_start DATE NOT NULL,
    period_end DATE NOT NULL,
    category VARCHAR(100),                           -- flour, dairy, packaging, etc.
    budgeted_amount DECIMAL(10, 2) NOT NULL,
    actual_spent DECIMAL(10, 2) DEFAULT 0.00,
    variance DECIMAL(10, 2) DEFAULT 0.00,
    variance_percentage DECIMAL(5, 2) DEFAULT 0.00,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(tenant_id, period_start, category)
);

Indexes for Performance

CREATE INDEX idx_po_tenant_status ON purchase_orders(tenant_id, status);
CREATE INDEX idx_po_supplier ON purchase_orders(supplier_id);
CREATE INDEX idx_po_expected_delivery ON purchase_orders(tenant_id, expected_delivery_date);
CREATE INDEX idx_po_items_po ON purchase_order_items(purchase_order_id);
CREATE INDEX idx_supplier_products_supplier ON supplier_products(supplier_id);
CREATE INDEX idx_supplier_products_ingredient ON supplier_products(tenant_id, ingredient_id);
CREATE INDEX idx_projections_date ON inventory_projections(tenant_id, projection_date);

Business Logic Examples

Automated Procurement Needs Calculation

async def calculate_procurement_needs(tenant_id: UUID, days_ahead: int = 14) -> list[ProcurementNeed]:
    """
    Calculate ingredient procurement needs for next N days.
    Accounts for: current stock, production plans, forecasts, lead times, safety stock.
    """
    needs = []

    # Get all ingredients
    ingredients = await get_all_ingredients(tenant_id)

    for ingredient in ingredients:
        # Get current stock
        current_stock = await get_current_stock_level(tenant_id, ingredient.id)

        # Get planned consumption from production schedules
        planned_consumption = await get_planned_ingredient_consumption(
            tenant_id,
            ingredient.id,
            days_ahead=days_ahead
        )

        # Get forecasted consumption (if no production plans)
        forecast_consumption = await get_forecasted_consumption(
            tenant_id,
            ingredient.id,
            days_ahead=days_ahead
        )

        # Total projected consumption
        projected_consumption = max(planned_consumption, forecast_consumption)

        # Get reorder point and safety stock
        reorder_config = await get_reorder_point(tenant_id, ingredient.id)
        reorder_point = reorder_config.reorder_point
        safety_stock = reorder_config.safety_stock
        eoq = reorder_config.economic_order_quantity

        # Get supplier lead time
        supplier = await get_preferred_supplier(tenant_id, ingredient.id)
        lead_time_days = supplier.lead_time_days if supplier else 3

        # Calculate projected stock at end of period
        projected_stock_end = current_stock - projected_consumption

        # Determine if order needed
        if projected_stock_end < reorder_point:
            # Calculate order quantity
            shortage = reorder_point - projected_stock_end
            order_quantity = max(shortage + safety_stock, eoq)

            # Estimate stockout date
            daily_consumption = projected_consumption / days_ahead
            days_until_stockout = current_stock / daily_consumption if daily_consumption > 0 else 999
            stockout_date = date.today() + timedelta(days=int(days_until_stockout))

            # Determine urgency
            if days_until_stockout <= lead_time_days:
                urgency = 'critical'
            elif days_until_stockout <= lead_time_days * 1.5:
                urgency = 'high'
            elif days_until_stockout <= lead_time_days * 2:
                urgency = 'medium'
            else:
                urgency = 'low'

            # Get estimated cost
            unit_price = await get_ingredient_unit_price(tenant_id, ingredient.id)
            estimated_cost = order_quantity * unit_price

            # Create procurement need
            need = ProcurementNeed(
                tenant_id=tenant_id,
                ingredient_id=ingredient.id,
                ingredient_name=ingredient.name,
                current_stock=current_stock,
                projected_consumption=projected_consumption,
                safety_stock=safety_stock,
                reorder_point=reorder_point,
                recommended_order_quantity=order_quantity,
                recommended_order_unit=ingredient.unit,
                urgency=urgency,
                estimated_stockout_date=stockout_date,
                recommended_supplier_id=supplier.id if supplier else None,
                estimated_cost=estimated_cost,
                status='pending'
            )

            db.add(need)
            needs.append(need)

    await db.commit()

    # Publish procurement needs event
    if needs:
        await publish_event('procurement', 'procurement.needs_calculated', {
            'tenant_id': str(tenant_id),
            'needs_count': len(needs),
            'critical_count': sum(1 for n in needs if n.urgency == 'critical'),
            'total_estimated_cost': sum(n.estimated_cost for n in needs)
        })

    logger.info("Procurement needs calculated",
                tenant_id=str(tenant_id),
                needs_count=len(needs),
                days_ahead=days_ahead)

    return needs

Automated Purchase Order Generation

async def generate_purchase_orders(tenant_id: UUID) -> list[PurchaseOrder]:
    """
    Generate purchase orders from pending procurement needs.
    Groups items by supplier for efficiency.
    """
    # Get pending procurement needs
    needs = await db.query(ProcurementNeed).filter(
        ProcurementNeed.tenant_id == tenant_id,
        ProcurementNeed.status == 'pending',
        ProcurementNeed.urgency.in_(['critical', 'high'])
    ).all()

    if not needs:
        return []

    # Group needs by supplier
    supplier_groups = {}
    for need in needs:
        supplier_id = need.recommended_supplier_id
        if supplier_id not in supplier_groups:
            supplier_groups[supplier_id] = []
        supplier_groups[supplier_id].append(need)

    # Create purchase order per supplier
    purchase_orders = []
    for supplier_id, supplier_needs in supplier_groups.items():
        # Get supplier details
        supplier = await get_supplier(supplier_id)

        # Generate PO number
        po_number = await generate_po_number(tenant_id)

        # Calculate expected delivery date
        lead_time = supplier.lead_time_days or 3
        expected_delivery = date.today() + timedelta(days=lead_time)

        # Create purchase order
        po = PurchaseOrder(
            tenant_id=tenant_id,
            po_number=po_number,
            supplier_id=supplier.id,
            supplier_name=supplier.name,
            order_date=date.today(),
            expected_delivery_date=expected_delivery,
            status='draft',
            payment_terms=supplier.payment_terms or 'Net 30',
            delivery_address=await get_default_delivery_address(tenant_id),
            contact_person=supplier.contact_name,
            contact_phone=supplier.phone,
            contact_email=supplier.email,
            created_by=tenant_id  # System-generated
        )
        db.add(po)
        await db.flush()  # Get po.id

        # Add items to PO
        subtotal = Decimal('0.00')
        for need in supplier_needs:
            # Get supplier pricing
            supplier_product = await get_supplier_product(
                supplier_id,
                need.ingredient_id
            )

            unit_price = supplier_product.unit_price
            quantity = need.recommended_order_quantity
            line_total = unit_price * quantity

            po_item = PurchaseOrderItem(
                tenant_id=tenant_id,
                purchase_order_id=po.id,
                ingredient_id=need.ingredient_id,
                ingredient_name=need.ingredient_name,
                quantity_ordered=quantity,
                unit=need.recommended_order_unit,
                unit_price=unit_price,
                line_total=line_total
            )
            db.add(po_item)
            subtotal += line_total

            # Mark need as ordered
            need.status = 'ordered'

        # Calculate totals (Spanish IVA 10% on food products)
        tax_amount = subtotal * Decimal('0.10')
        total_amount = subtotal + tax_amount

        po.subtotal = subtotal
        po.tax_amount = tax_amount
        po.total_amount = total_amount

        purchase_orders.append(po)

    await db.commit()

    # Publish event
    await publish_event('procurement', 'purchase_orders.generated', {
        'tenant_id': str(tenant_id),
        'po_count': len(purchase_orders),
        'total_value': sum(po.total_amount for po in purchase_orders)
    })

    logger.info("Purchase orders generated",
                tenant_id=str(tenant_id),
                count=len(purchase_orders))

    return purchase_orders

🆕 Internal Transfer Pricing Calculation (NEW)

async def calculate_transfer_pricing(
    tenant_id: UUID,
    items: list[dict],
    markup_percentage: Optional[float] = None
) -> dict:
    """
    Calculate transfer prices for internal purchase orders between parent and child tenants.
    Uses recipe cost explosion for locally-produced items, average cost for purchased goods.

    ⚠️ NOTE: Helper functions _get_recipe_cost() and _get_inventory_average_cost()
    are placeholders pending full implementation.

    Args:
        tenant_id: Parent tenant ID (source of goods)
        items: List of items with ingredient_id/recipe_id and quantity
        markup_percentage: Optional markup (e.g., 10.0 for 10% markup)

    Returns:
        Dictionary with item pricing details and totals
    """
    from decimal import Decimal
    from shared.clients import get_recipe_client, get_inventory_client

    recipe_client = get_recipe_client()
    inventory_client = get_inventory_client()

    pricing_details = []
    subtotal = Decimal('0.00')

    for item in items:
        item_type = item.get('item_type')  # 'finished_good' or 'raw_material'
        item_id = item.get('item_id')
        quantity = Decimal(str(item.get('quantity', 0)))
        unit = item.get('unit', 'kg')

        if item_type == 'finished_good':
            # Recipe-based costing (cost explosion)
            # ⚠️ This is a placeholder - actual implementation pending
            recipe = await recipe_client.get_recipe(tenant_id, item_id)

            # Calculate total ingredient cost for the recipe
            ingredient_cost = Decimal('0.00')
            for ingredient in recipe.get('ingredients', []):
                ingredient_id = ingredient['ingredient_id']
                ingredient_qty = Decimal(str(ingredient['quantity']))

                # Get current average cost from inventory
                avg_cost = await _get_inventory_average_cost(
                    tenant_id,
                    ingredient_id
                )
                ingredient_cost += avg_cost * ingredient_qty

            # Add production overhead (estimated 20% of material cost)
            production_overhead = ingredient_cost * Decimal('0.20')
            base_cost = ingredient_cost + production_overhead

            # Calculate cost per unit (recipe yield)
            recipe_yield = Decimal(str(recipe.get('yield_quantity', 1)))
            unit_cost = base_cost / recipe_yield if recipe_yield > 0 else Decimal('0.00')

        else:  # raw_material
            # Use average inventory cost
            # ⚠️ This is a placeholder - actual implementation pending
            unit_cost = await _get_inventory_average_cost(tenant_id, item_id)

        # Apply markup if specified
        if markup_percentage:
            markup_multiplier = Decimal('1.0') + (Decimal(str(markup_percentage)) / Decimal('100'))
            unit_price = unit_cost * markup_multiplier
        else:
            unit_price = unit_cost

        # Calculate line total
        line_total = unit_price * quantity
        subtotal += line_total

        pricing_details.append({
            'item_id': item_id,
            'item_type': item_type,
            'item_name': item.get('item_name'),
            'quantity': float(quantity),
            'unit': unit,
            'base_cost': float(unit_cost),
            'unit_price': float(unit_price),
            'line_total': float(line_total),
            'markup_applied': markup_percentage is not None
        })

    # Calculate tax (Spanish IVA 10% on food products)
    tax_rate = Decimal('0.10')
    tax_amount = subtotal * tax_rate
    total_amount = subtotal + tax_amount

    result = {
        'tenant_id': str(tenant_id),
        'items': pricing_details,
        'subtotal': float(subtotal),
        'tax_amount': float(tax_amount),
        'total_amount': float(total_amount),
        'markup_percentage': markup_percentage,
        'pricing_method': 'cost_based'
    }

    logger.info("Transfer pricing calculated",
                tenant_id=str(tenant_id),
                item_count=len(items),
                total_amount=float(total_amount))

    return result


# ⚠️ PLACEHOLDER HELPER FUNCTIONS - Full implementation pending
async def _get_recipe_cost(tenant_id: UUID, recipe_id: UUID) -> Decimal:
    """
    Calculate total cost for a recipe by exploding ingredient costs.
    ⚠️ This is a placeholder - needs integration with Recipe Service.
    """
    # TODO: Implement full recipe cost explosion
    # 1. Fetch recipe with all ingredients
    # 2. Get current inventory average cost for each ingredient
    # 3. Calculate total ingredient cost
    # 4. Add production overhead
    return Decimal('0.00')


async def _get_inventory_average_cost(tenant_id: UUID, ingredient_id: UUID) -> Decimal:
    """
    Get average cost per unit from inventory service.
    ⚠️ This is a placeholder - needs integration with Inventory Service.
    """
    # TODO: Implement inventory average cost lookup
    # Uses weighted average cost from recent stock receipts
    return Decimal('0.00')

Economic Order Quantity (EOQ) Calculation

def calculate_eoq(
    annual_demand: float,
    ordering_cost_per_order: float,
    holding_cost_per_unit_per_year: float
) -> float:
    """
    Calculate Economic Order Quantity using Wilson's formula.
    EOQ = sqrt((2 * D * S) / H)
    Where:
    - D = Annual demand
    - S = Ordering cost per order
    - H = Holding cost per unit per year
    """
    if holding_cost_per_unit_per_year == 0:
        return 0

    eoq = math.sqrt(
        (2 * annual_demand * ordering_cost_per_order) / holding_cost_per_unit_per_year
    )

    return round(eoq, 2)

async def calculate_reorder_point(
    tenant_id: UUID,
    ingredient_id: UUID
) -> ReorderPoint:
    """
    Calculate reorder point and safety stock for ingredient.
    Reorder Point = (Average Daily Consumption × Lead Time) + Safety Stock
    Safety Stock = Z-score × σ × sqrt(Lead Time)
    """
    # Get historical consumption data (last 90 days)
    consumption_history = await get_consumption_history(
        tenant_id,
        ingredient_id,
        days=90
    )

    # Calculate average daily consumption
    if len(consumption_history) > 0:
        avg_daily_consumption = sum(consumption_history) / len(consumption_history)
        std_dev_consumption = statistics.stdev(consumption_history) if len(consumption_history) > 1 else 0
    else:
        avg_daily_consumption = 0
        std_dev_consumption = 0

    # Get supplier lead time
    supplier = await get_preferred_supplier(tenant_id, ingredient_id)
    lead_time_days = supplier.lead_time_days if supplier else 3

    # Calculate safety stock (95% service level, Z=1.65)
    z_score = 1.65
    safety_stock = z_score * std_dev_consumption * math.sqrt(lead_time_days)

    # Calculate reorder point
    reorder_point = (avg_daily_consumption * lead_time_days) + safety_stock

    # Calculate EOQ
    annual_demand = avg_daily_consumption * 365
    ordering_cost = 25.0  # Estimated administrative cost per order
    unit_price = await get_ingredient_unit_price(tenant_id, ingredient_id)
    holding_cost_rate = 0.20  # 20% of unit cost per year
    holding_cost = unit_price * holding_cost_rate

    eoq = calculate_eoq(annual_demand, ordering_cost, holding_cost)

    # Store reorder point configuration
    reorder_config = await db.get(ReorderPoint, {'tenant_id': tenant_id, 'ingredient_id': ingredient_id})
    if not reorder_config:
        reorder_config = ReorderPoint(
            tenant_id=tenant_id,
            ingredient_id=ingredient_id,
            ingredient_name=await get_ingredient_name(ingredient_id)
        )
        db.add(reorder_config)

    reorder_config.reorder_point = round(reorder_point, 2)
    reorder_config.safety_stock = round(safety_stock, 2)
    reorder_config.economic_order_quantity = round(eoq, 2)
    reorder_config.average_daily_consumption = round(avg_daily_consumption, 2)
    reorder_config.lead_time_days = lead_time_days
    reorder_config.calculation_method = 'auto_advanced'
    reorder_config.last_calculated_at = datetime.utcnow()

    await db.commit()

    return reorder_config

Supplier Recommendation Engine

async def recommend_supplier(
    tenant_id: UUID,
    ingredient_id: UUID,
    quantity: float
) -> UUID:
    """
    Recommend best supplier based on price, quality, and delivery performance.
    Scoring: 40% price, 30% quality, 30% delivery
    """
    # Get all suppliers for ingredient
    suppliers = await db.query(SupplierProduct).filter(
        SupplierProduct.tenant_id == tenant_id,
        SupplierProduct.ingredient_id == ingredient_id,
        SupplierProduct.is_active == True
    ).all()

    if not suppliers:
        return None

    supplier_scores = []
    for supplier_product in suppliers:
        # Get supplier performance metrics
        supplier = await get_supplier(supplier_product.supplier_id)
        performance = await get_supplier_performance(supplier.id)

        # Price score (lower is better, normalized 0-100)
        prices = [sp.unit_price for sp in suppliers]
        min_price = min(prices)
        max_price = max(prices)
        if max_price > min_price:
            price_score = 100 * (1 - (supplier_product.unit_price - min_price) / (max_price - min_price))
        else:
            price_score = 100

        # Quality score (0-100, from supplier ratings)
        quality_score = supplier.quality_rating or 75

        # Delivery score (0-100, based on on-time delivery %)
        delivery_score = performance.on_time_delivery_percentage if performance else 80

        # Weighted total score
        total_score = (
            price_score * 0.40 +
            quality_score * 0.30 +
            delivery_score * 0.30
        )

        supplier_scores.append({
            'supplier_id': supplier.id,
            'supplier_name': supplier.name,
            'total_score': total_score,
            'price_score': price_score,
            'quality_score': quality_score,
            'delivery_score': delivery_score,
            'unit_price': supplier_product.unit_price
        })

    # Sort by total score descending
    supplier_scores.sort(key=lambda x: x['total_score'], reverse=True)

    # Return best supplier
    return supplier_scores[0]['supplier_id'] if supplier_scores else None

Events & Messaging

Published Events (RabbitMQ)

Exchange: procurement.events Routing Keys: po.approved, po.rejected, po.sent_to_supplier, delivery.received, procurement.needs_calculated, procurement.po_created, procurement.stockout_risk

Purchase Order Lifecycle Events (🆕)

PO Approved Event - Published when a PO is approved

  • Routing Key: po.approved
  • Consumed By: Notification service (sends email to supplier)
  • Trigger: User approves PO in dashboard or via API
{
    "event_id": "uuid",
    "event_type": "po.approved",
    "service_name": "procurement",
    "timestamp": "2025-11-12T10:30:00Z",
    "data": {
        "tenant_id": "uuid",
        "po_id": "uuid",
        "po_number": "PO-2025-1112-001",
        "supplier_id": "uuid",
        "supplier_name": "Harinas García",
        "supplier_email": "pedidos@harinasgarcia.es",
        "supplier_phone": "+34612345678",
        "total_amount": 850.00,
        "currency": "EUR",
        "required_delivery_date": "2025-11-19",
        "items": [
            {
                "product_name": "Harina de Trigo T-55",
                "ordered_quantity": 100.0,
                "unit_of_measure": "kg",
                "unit_price": 0.85,
                "line_total": 85.00
            }
        ],
        "approved_by": "uuid",
        "approved_at": "2025-11-12T10:30:00Z"
    }
}

PO Rejected Event - Published when a PO is rejected

  • Routing Key: po.rejected
  • Consumed By: Notification service (notifies stakeholders)
  • Trigger: User rejects PO with reason
{
    "event_id": "uuid",
    "event_type": "po.rejected",
    "service_name": "procurement",
    "timestamp": "2025-11-12T10:30:00Z",
    "data": {
        "tenant_id": "uuid",
        "po_id": "uuid",
        "po_number": "PO-2025-1112-001",
        "supplier_id": "uuid",
        "supplier_name": "Harinas García",
        "rejection_reason": "Price too high compared to market rate",
        "rejected_by": "uuid",
        "rejected_at": "2025-11-12T10:30:00Z"
    }
}

Delivery Received Event - Published when a delivery is recorded

  • Routing Key: delivery.received
  • Consumed By: Inventory service (automatically updates stock)
  • Trigger: User records delivery receipt in DeliveryReceiptModal
{
    "event_id": "uuid",
    "event_type": "delivery.received",
    "service_name": "procurement",
    "timestamp": "2025-11-12T10:30:00Z",
    "data": {
        "tenant_id": "uuid",
        "delivery_id": "uuid",
        "po_id": "uuid",
        "received_by": "uuid",
        "received_at": "2025-11-12T10:30:00Z",
        "items": [
            {
                "inventory_product_id": "uuid",
                "product_name": "Harina de Trigo T-55",
                "ordered_quantity": 100.0,
                "delivered_quantity": 98.0,
                "accepted_quantity": 95.0,
                "rejected_quantity": 3.0,
                "batch_lot_number": "LOT-2025-1112",
                "expiry_date": "2025-12-12",
                "rejection_reason": "3 damaged bags"
            }
        ]
    }
}

Legacy/Other Events

Exchange: procurement Routing Keys: procurement.needs_calculated, procurement.po_created, procurement.stockout_risk

Procurement Needs Calculated Event

{
    "event_type": "procurement_needs_calculated",
    "tenant_id": "uuid",
    "needs_count": 12,
    "critical_count": 3,
    "high_count": 5,
    "total_estimated_cost": 2450.00,
    "critical_ingredients": [
        {"ingredient_id": "uuid", "ingredient_name": "Harina de Trigo", "days_until_stockout": 2},
        {"ingredient_id": "uuid", "ingredient_name": "Levadura", "days_until_stockout": 3}
    ],
    "timestamp": "2025-11-06T08:00:00Z"
}

Purchase Order Created Event

{
    "event_type": "purchase_order_created",
    "tenant_id": "uuid",
    "po_id": "uuid",
    "po_number": "PO-2025-1106-001",
    "supplier_id": "uuid",
    "supplier_name": "Harinas García",
    "total_amount": 850.00,
    "item_count": 5,
    "expected_delivery_date": "2025-11-10",
    "status": "sent",
    "timestamp": "2025-11-06T10:30:00Z"
}

Purchase Order Received Event

{
    "event_type": "purchase_order_received",
    "tenant_id": "uuid",
    "po_id": "uuid",
    "po_number": "PO-2025-1106-001",
    "supplier_id": "uuid",
    "expected_delivery_date": "2025-11-10",
    "actual_delivery_date": "2025-11-09",
    "on_time": true,
    "quality_issues": false,
    "timestamp": "2025-11-09T07:30:00Z"
}

Stockout Risk Alert

{
    "event_type": "stockout_risk",
    "tenant_id": "uuid",
    "ingredient_id": "uuid",
    "ingredient_name": "Mantequilla",
    "current_stock": 5.5,
    "unit": "kg",
    "projected_consumption_7days": 12.0,
    "days_until_stockout": 3,
    "risk_level": "high",
    "recommended_action": "Place order immediately",
    "timestamp": "2025-11-06T09:00:00Z"
}

🆕 Enterprise Internal Transfer Events (NEW)

Internal Transfer Created Event - Published when an internal PO is created between parent and child

  • Routing Key: internal_transfer.created
  • Consumed By: Distribution service (for delivery route planning)
  • Trigger: Child tenant creates internal transfer PO
{
    "event_id": "uuid",
    "event_type": "internal_transfer.created",
    "service_name": "procurement",
    "timestamp": "2025-11-12T10:30:00Z",
    "data": {
        "tenant_id": "uuid",
        "po_id": "uuid",
        "po_number": "INT-2025-1112-001",
        "parent_tenant_id": "uuid",
        "child_tenant_id": "uuid",
        "transfer_type": "finished_goods",
        "status": "pending_approval",
        "items": [
            {
                "item_type": "finished_good",
                "recipe_id": "uuid",
                "product_name": "Pan de Molde",
                "quantity": 50.0,
                "unit": "kg",
                "transfer_price": 2.50,
                "line_total": 125.00
            }
        ],
        "subtotal": 125.00,
        "tax_amount": 12.50,
        "total_amount": 137.50,
        "requested_delivery_date": "2025-11-14",
        "created_by": "uuid"
    }
}

Internal Transfer Approved Event - Published when parent approves internal transfer

  • Routing Key: internal_transfer.approved
  • Consumed By: Distribution service (creates shipment), Inventory service (reserves stock)
  • Trigger: Parent tenant approves internal transfer request
{
    "event_id": "uuid",
    "event_type": "internal_transfer.approved",
    "service_name": "procurement",
    "timestamp": "2025-11-12T14:00:00Z",
    "data": {
        "tenant_id": "uuid",
        "po_id": "uuid",
        "po_number": "INT-2025-1112-001",
        "parent_tenant_id": "uuid",
        "child_tenant_id": "uuid",
        "total_amount": 137.50,
        "requested_delivery_date": "2025-11-14",
        "approved_by": "uuid",
        "approved_at": "2025-11-12T14:00:00Z",
        "items": [
            {
                "item_id": "uuid",
                "quantity": 50.0,
                "unit": "kg"
            }
        ]
    }
}

Internal Transfer Rejected Event - Published when parent rejects internal transfer

  • Routing Key: internal_transfer.rejected
  • Consumed By: Notification service (notifies child tenant)
  • Trigger: Parent tenant rejects internal transfer request
{
    "event_id": "uuid",
    "event_type": "internal_transfer.rejected",
    "service_name": "procurement",
    "timestamp": "2025-11-12T14:00:00Z",
    "data": {
        "tenant_id": "uuid",
        "po_id": "uuid",
        "po_number": "INT-2025-1112-001",
        "parent_tenant_id": "uuid",
        "child_tenant_id": "uuid",
        "rejection_reason": "Insufficient production capacity for requested date",
        "rejected_by": "uuid",
        "rejected_at": "2025-11-12T14:00:00Z"
    }
}

Consumed Events

  • From Production: Production schedules trigger procurement needs calculation
  • From Forecasting: Demand forecasts inform procurement planning
  • From Inventory: Stock level changes update projections
  • From Orchestrator: Daily procurement planning trigger
  • 🆕 From Distribution (NEW): Shipment delivery completed → Update internal PO status to 'delivered'
  • 🆕 From Tenant (NEW): Child outlet created → Setup default procurement settings for new location

Custom Metrics (Prometheus)

# Procurement metrics
procurement_needs_total = Counter(
    'procurement_needs_total',
    'Total procurement needs identified',
    ['tenant_id', 'urgency']
)

purchase_orders_total = Counter(
    'purchase_orders_total',
    'Total purchase orders created',
    ['tenant_id', 'supplier_id', 'status']
)

purchase_order_value_euros = Histogram(
    'purchase_order_value_euros',
    'Purchase order value distribution',
    ['tenant_id'],
    buckets=[100, 250, 500, 1000, 2000, 5000, 10000]
)

# Supplier performance metrics
supplier_delivery_time_days = Histogram(
    'supplier_delivery_time_days',
    'Supplier delivery time',
    ['tenant_id', 'supplier_id'],
    buckets=[1, 2, 3, 5, 7, 10, 14, 21]
)

supplier_on_time_delivery = Gauge(
    'supplier_on_time_delivery_percentage',
    'Supplier on-time delivery rate',
    ['tenant_id', 'supplier_id']
)

# Stock optimization metrics
stockout_events_total = Counter(
    'stockout_events_total',
    'Total stockout events',
    ['tenant_id', 'ingredient_id']
)

inventory_turnover_ratio = Gauge(
    'inventory_turnover_ratio',
    'Inventory turnover ratio',
    ['tenant_id', 'category']
)

Configuration

Environment Variables

Service Configuration:

  • PORT - Service port (default: 8011)
  • DATABASE_URL - PostgreSQL connection 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
  • 🆕 Tenant Service (NEW) - Tenant hierarchy for internal transfers (parent/child relationships)
  • 🆕 Distribution Service (NEW) - Delivery route planning for approved internal transfers
  • PostgreSQL - Procurement data
  • Redis - Calculation caching
  • RabbitMQ - Event publishing

Dependents

  • Inventory Service - Purchase orders create inventory receipts
  • Accounting Service - Purchase orders for expense tracking
  • Notification Service - Stockout and PO alerts
  • AI Insights Service - Procurement optimization recommendations
  • Frontend Dashboard - Procurement management UI
  • 🆕 Distribution Service (NEW) - Internal transfer POs feed into delivery route optimization
  • 🆕 Forecasting Service (NEW) - Transfer pricing data informs cost predictions

Business Value for VUE Madrid

Problem Statement

Spanish bakeries struggle with:

  • Manual ordering leading to stockouts or overstock
  • No visibility into future ingredient needs
  • Reactive procurement (order when empty, too late)
  • No systematic supplier performance tracking
  • Manual price comparison across suppliers
  • Excess inventory tying up cash

Solution

Bakery-IA Procurement Service provides:

  • Automated Planning: System calculates what and when to order
  • Stockout Prevention: 85-95% reduction in production delays
  • Cost Optimization: Supplier recommendations based on data
  • Inventory Optimization: 20-30% less inventory with same service
  • Supplier Management: Performance tracking and leverage

Quantifiable Impact

Cost Savings:

  • €200-400/month from optimized ordering (5-15% procurement savings)
  • €100-300/month from reduced excess inventory
  • €150-500/month from stockout prevention (lost production)
  • Total: €450-1,200/month savings

Time Savings:

  • 8-12 hours/week on manual ordering and tracking
  • 2-3 hours/week on supplier communication
  • 1-2 hours/week on inventory checks
  • Total: 11-17 hours/week saved

Operational Improvements:

  • 85-95% stockout prevention rate
  • 20-30% inventory reduction
  • 15-25% supplier delivery improvement
  • 10-20% less spoilage from overstock

Target Market Fit (Spanish Bakeries)

  • Cash Flow Sensitive: Spanish SMBs need optimal inventory investment
  • Supplier Relationships: Data enables better supplier negotiations
  • Regulatory: Proper PO documentation for Spanish tax compliance
  • Growth: Automation enables scaling without procurement staff

ROI Calculation

Investment: €0 additional (included in platform subscription) Monthly Savings: €450-1,200 Annual ROI: €5,400-14,400 value per bakery Payback: Immediate (included in subscription)


Copyright © 2025 Bakery-IA. All rights reserved.