#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Demo Purchase Orders Seeding Script for Procurement Service Creates realistic PO scenarios in various states for demo purposes This script creates: - 3 PENDING_APPROVAL POs (created today, need user action) - 2 APPROVED POs (approved yesterday, in progress) - 1 AUTO_APPROVED PO (small amount, trusted supplier) - 2 COMPLETED POs (delivered last week) - 1 REJECTED PO (quality concerns) - 1 CANCELLED PO (supplier unavailable) """ import asyncio import uuid import sys import os import random from datetime import datetime, timezone, timedelta, date from pathlib import Path from decimal import Decimal from typing import List, Dict, Any # Add app to path sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy import select import structlog from app.models.purchase_order import ( PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus ) # Import reasoning helper functions for i18n support sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) from shared.schemas.reasoning_types import ( create_po_reasoning_low_stock, create_po_reasoning_supplier_contract ) # Configure logging logger = structlog.get_logger() # Demo tenant IDs (match those from orders service) DEMO_TENANT_IDS = [ uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"), # San Pablo uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # La Espiga ] # System user ID for auto-approvals SYSTEM_USER_ID = uuid.UUID("50000000-0000-0000-0000-000000000004") # Hardcoded base supplier IDs (must match those in suppliers seed script) BASE_SUPPLIER_IDS = [ uuid.UUID("40000000-0000-0000-0000-000000000001"), # Molinos San José S.L. (high trust) uuid.UUID("40000000-0000-0000-0000-000000000002"), # Lácteos del Valle S.A. (medium trust) uuid.UUID("40000000-0000-0000-0000-000000000005"), # Lesaffre Ibérica (low trust) ] # Supplier lead times (days) for realistic supply chain modeling SUPPLIER_LEAD_TIMES = { "Molinos San José S.L.": 2, # 2-day delivery (trusted, local) "Lácteos del Valle S.A.": 3, # 3-day delivery (regional) "Lesaffre Ibérica": 4 # 4-day delivery (national) } # Daily consumption rates (kg/day) for realistic stock depletion modeling # These match real bakery production needs DAILY_CONSUMPTION_RATES = { "Harina de Trigo T55": 50.0, "Harina Integral Ecológica": 15.0, "Mantequilla sin Sal 82% MG": 8.0, "Huevos Frescos Categoría A": 100.0, # units, not kg, but modeled as kg for consistency "Levadura Seca": 2.5, "Sal Fina": 3.0, "Aceite de Oliva Virgen": 5.0, "Azúcar Moreno": 6.0, "Semillas de Girasol": 2.0, "Miel de Azahar": 1.5, "Chocolate Negro 70%": 4.0, "Nueces Peladas": 3.5, "Pasas Sultanas": 2.5 } # Reorder points (kg) - when to trigger PO REORDER_POINTS = { "Harina de Trigo T55": 150.0, # Critical ingredient "Harina Integral Ecológica": 50.0, "Mantequilla sin Sal 82% MG": 25.0, "Huevos Frescos Categoría A": 300.0, "Levadura Seca": 10.0, "Sal Fina": 20.0, "Aceite de Oliva Virgen": 15.0, "Azúcar Moreno": 20.0, "Semillas de Girasol": 10.0, "Miel de Azahar": 5.0, "Chocolate Negro 70%": 15.0, "Nueces Peladas": 12.0, "Pasas Sultanas": 10.0 } def get_demo_supplier_ids(tenant_id: uuid.UUID): """ Generate tenant-specific supplier IDs using XOR strategy with hardcoded base IDs. This maintains consistency across services without cross-database access. """ # Generate tenant-specific supplier IDs using XOR with tenant ID tenant_int = int(tenant_id.hex, 16) class SupplierRef: def __init__(self, supplier_id, supplier_name, trust_level): self.id = supplier_id self.name = supplier_name self.trust_score = trust_level suppliers = [] trust_scores = [0.92, 0.75, 0.65] # High, medium, low trust supplier_names = [ "Molinos San José S.L.", "Lácteos del Valle S.A.", "Lesaffre Ibérica" ] for i, base_id in enumerate(BASE_SUPPLIER_IDS): base_int = int(base_id.hex, 16) supplier_id = uuid.UUID(int=tenant_int ^ base_int) suppliers.append(SupplierRef( supplier_id, supplier_names[i], trust_scores[i] if i < len(trust_scores) else 0.5 )) return suppliers def get_simulated_stock_level(product_name: str, make_critical: bool = False) -> float: """ Simulate current stock level for demo purposes Args: product_name: Name of the product make_critical: If True, create critically low stock (< 1 day) Returns: Simulated current stock in kg """ daily_consumption = DAILY_CONSUMPTION_RATES.get(product_name, 5.0) if make_critical: # Critical: 0.5-6 hours worth of stock return round(daily_consumption * random.uniform(0.02, 0.25), 2) else: # Normal low stock: 1-3 days worth return round(daily_consumption * random.uniform(1.0, 3.0), 2) def calculate_product_urgency( product_name: str, current_stock: float, supplier_lead_time_days: int, reorder_point: float = None ) -> Dict[str, Any]: """ Calculate urgency metrics for a product based on supply chain dynamics Args: product_name: Name of the product current_stock: Current stock level in kg supplier_lead_time_days: Supplier delivery lead time in days reorder_point: Reorder point threshold (optional) Returns: Dictionary with urgency metrics """ daily_consumption = DAILY_CONSUMPTION_RATES.get(product_name, 5.0) reorder_pt = reorder_point or REORDER_POINTS.get(product_name, 50.0) # Calculate days until depletion if daily_consumption > 0: days_until_depletion = current_stock / daily_consumption else: days_until_depletion = 999.0 # Calculate safety margin (days until depletion - supplier lead time) safety_margin_days = days_until_depletion - supplier_lead_time_days # Determine criticality based on safety margin if safety_margin_days <= 0: criticality = "critical" # Already late or will run out before delivery! order_urgency_reason = f"Stock depletes in {round(days_until_depletion, 1)} days, but delivery takes {supplier_lead_time_days} days" elif safety_margin_days <= 0.5: criticality = "urgent" # Must order TODAY order_urgency_reason = f"Only {round(safety_margin_days * 24, 1)} hours margin before stockout" elif safety_margin_days <= 1: criticality = "important" # Should order today order_urgency_reason = f"Only {round(safety_margin_days, 1)} day margin" else: criticality = "normal" order_urgency_reason = "Standard replenishment" return { "product_name": product_name, "current_stock_kg": round(current_stock, 2), "daily_consumption_kg": round(daily_consumption, 2), "days_until_depletion": round(days_until_depletion, 2), "reorder_point_kg": round(reorder_pt, 2), "safety_stock_days": 3, # Standard 3-day safety stock "safety_margin_days": round(safety_margin_days, 2), "criticality": criticality, "urgency_reason": order_urgency_reason } def determine_overall_po_urgency(product_details: List[Dict[str, Any]]) -> str: """ Determine overall PO urgency based on most critical product Args: product_details: List of product urgency dictionaries Returns: Overall urgency: "critical", "urgent", "important", or "normal" """ criticalities = [p.get("criticality", "normal") for p in product_details] if "critical" in criticalities: return "critical" elif "urgent" in criticalities: return "urgent" elif "important" in criticalities: return "important" else: return "normal" async def create_purchase_order( db: AsyncSession, tenant_id: uuid.UUID, supplier, status: PurchaseOrderStatus, total_amount: Decimal, created_offset_days: int = 0, auto_approved: bool = False, priority: str = "normal", items_data: list = None ) -> PurchaseOrder: """Create a purchase order with items""" created_at = datetime.now(timezone.utc) + timedelta(days=created_offset_days) required_delivery = created_at + timedelta(days=random.randint(3, 7)) # Generate unique PO number while True: po_number = f"PO-{datetime.now().year}-{random.randint(100, 999)}" # Check if PO number already exists in the database existing_po = await db.execute( select(PurchaseOrder).where(PurchaseOrder.po_number == po_number).limit(1) ) if not existing_po.scalar_one_or_none(): break # Calculate amounts subtotal = total_amount tax_amount = subtotal * Decimal("0.10") # 10% IVA shipping_cost = Decimal(str(random.uniform(0, 20))) total = subtotal + tax_amount + shipping_cost # Generate reasoning for JTBD dashboard (if columns exist after migration) days_until_delivery = (required_delivery - created_at).days # Generate structured reasoning_data with supply chain intelligence reasoning_data = None try: # Get product names from items_data items_list = items_data or [] # CRITICAL FIX: Use 'name' key, not 'product_name', to match items_data structure product_names = [item.get('name', item.get('product_name', f"Product {i+1}")) for i, item in enumerate(items_list)] if not product_names: product_names = ["Demo Product"] # Get supplier lead time supplier_lead_time = SUPPLIER_LEAD_TIMES.get(supplier.name, 3) if status == PurchaseOrderStatus.pending_approval: # Enhanced low stock detection with per-product urgency analysis product_details = [] estimated_loss = 0.0 for i, item in enumerate(items_list): product_name = item.get('name', item.get('product_name', f"Product {i+1}")) # Simulate current stock - make first item critical for demo impact make_critical = (i == 0) and (priority == "urgent") current_stock = get_simulated_stock_level(product_name, make_critical=make_critical) # Calculate product-specific urgency urgency_info = calculate_product_urgency( product_name=product_name, current_stock=current_stock, supplier_lead_time_days=supplier_lead_time, reorder_point=item.get('reorder_point') ) product_details.append(urgency_info) # Estimate production loss for critical items if urgency_info["criticality"] in ["critical", "urgent"]: # Rough estimate: lost production value estimated_loss += item.get("unit_price", 1.0) * item.get("quantity", 10) * 1.5 # Determine overall urgency overall_urgency = determine_overall_po_urgency(product_details) # Find affected production batches (demo: simulate batch names) affected_batches = [] critical_products = [p for p in product_details if p["criticality"] in ["critical", "urgent"]] if critical_products: # Simulate batch numbers that would be affected affected_batches = ["BATCH-TODAY-001", "BATCH-TODAY-002"] if overall_urgency == "critical" else \ ["BATCH-TOMORROW-001"] if overall_urgency == "urgent" else [] # Create enhanced reasoning with detailed supply chain intelligence reasoning_data = create_po_reasoning_low_stock( supplier_name=supplier.name, product_names=product_names, # Legacy compatibility # Enhanced parameters product_details=product_details, supplier_lead_time_days=supplier_lead_time, order_urgency=overall_urgency, affected_production_batches=affected_batches, estimated_production_loss_eur=estimated_loss if estimated_loss > 0 else None ) elif auto_approved: # Supplier contract/auto-approval reasoning reasoning_data = create_po_reasoning_supplier_contract( supplier_name=supplier.name, product_names=product_names, contract_terms="monthly", contract_quantity=float(total_amount) ) except Exception as e: logger.warning(f"Failed to generate reasoning_data: {e}") logger.exception(e) pass # Create PO po = PurchaseOrder( id=uuid.uuid4(), tenant_id=tenant_id, supplier_id=supplier.id, po_number=po_number, status=status, priority=priority, order_date=created_at, required_delivery_date=required_delivery, subtotal=subtotal, tax_amount=tax_amount, shipping_cost=shipping_cost, discount_amount=Decimal("0.00"), total_amount=total, notes=f"Auto-generated demo PO from procurement plan" if not auto_approved else f"Auto-approved: Amount €{subtotal:.2f} within threshold", created_at=created_at, updated_at=created_at, created_by=SYSTEM_USER_ID, updated_by=SYSTEM_USER_ID ) # Set structured reasoning_data for i18n support if reasoning_data: try: po.reasoning_data = reasoning_data logger.debug(f"Set reasoning_data for PO {po_number}: {reasoning_data.get('type', 'unknown')}") except Exception as e: logger.warning(f"Failed to set reasoning_data for PO {po_number}: {e}") pass # Column might not exist yet # Set approval data if approved if status in [PurchaseOrderStatus.approved, PurchaseOrderStatus.sent_to_supplier, PurchaseOrderStatus.confirmed, PurchaseOrderStatus.completed]: po.approved_at = created_at + timedelta(hours=random.randint(1, 6)) po.approved_by = SYSTEM_USER_ID if auto_approved else uuid.uuid4() if auto_approved: po.notes = f"{po.notes}\nAuto-approved by system based on trust score and amount" # Set sent/confirmed dates if status in [PurchaseOrderStatus.sent_to_supplier, PurchaseOrderStatus.confirmed, PurchaseOrderStatus.completed]: po.sent_to_supplier_at = po.approved_at + timedelta(hours=2) if status in [PurchaseOrderStatus.confirmed, PurchaseOrderStatus.completed]: po.supplier_confirmation_date = po.sent_to_supplier_at + timedelta(hours=random.randint(4, 24)) db.add(po) await db.flush() # Create items if not items_data: items_data = [ {"name": "Harina de Trigo T55", "quantity": 100, "unit_price": 0.85, "uom": "kg"}, {"name": "Levadura Fresca", "quantity": 5, "unit_price": 4.50, "uom": "kg"}, {"name": "Sal Marina", "quantity": 10, "unit_price": 1.20, "uom": "kg"} ] for idx, item_data in enumerate(items_data, 1): ordered_qty = int(item_data["quantity"]) unit_price = Decimal(str(item_data["unit_price"])) line_total = Decimal(str(ordered_qty)) * unit_price item = PurchaseOrderItem( id=uuid.uuid4(), purchase_order_id=po.id, tenant_id=tenant_id, inventory_product_id=uuid.uuid4(), # Would link to actual inventory items product_code=f"PROD-{item_data['name'][:3].upper()}", product_name=item_data['name'], ordered_quantity=ordered_qty, received_quantity=ordered_qty if status == PurchaseOrderStatus.completed else 0, remaining_quantity=0 if status == PurchaseOrderStatus.completed else ordered_qty, unit_price=unit_price, line_total=line_total, unit_of_measure=item_data["uom"], item_notes=f"Demo item: {item_data['name']}" ) db.add(item) logger.info(f"Created PO: {po_number}", po_id=str(po.id), status=status.value, amount=float(total)) return po async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID): """Seed purchase orders for a specific tenant""" logger.info("Seeding purchase orders", tenant_id=str(tenant_id)) # Get demo supplier IDs (suppliers exist in the suppliers service) suppliers = get_demo_supplier_ids(tenant_id) # Group suppliers by trust level for easier access high_trust_suppliers = [s for s in suppliers if s.trust_score >= 0.85] medium_trust_suppliers = [s for s in suppliers if 0.6 <= s.trust_score < 0.85] low_trust_suppliers = [s for s in suppliers if s.trust_score < 0.6] # Use first supplier of each type if available supplier_high_trust = high_trust_suppliers[0] if high_trust_suppliers else suppliers[0] supplier_medium_trust = medium_trust_suppliers[0] if medium_trust_suppliers else suppliers[1] if len(suppliers) > 1 else suppliers[0] supplier_low_trust = low_trust_suppliers[0] if low_trust_suppliers else suppliers[-1] pos_created = [] # 1. PENDING_APPROVAL - Critical/Urgent (created today) po1 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.pending_approval, Decimal("1234.56"), created_offset_days=0, priority="high", items_data=[ {"name": "Harina Integral Ecológica", "quantity": 150, "unit_price": 1.20, "uom": "kg"}, {"name": "Semillas de Girasol", "quantity": 20, "unit_price": 3.50, "uom": "kg"}, {"name": "Miel de Azahar", "quantity": 10, "unit_price": 8.90, "uom": "kg"} ] ) pos_created.append(po1) # 2. PENDING_APPROVAL - Medium amount, new supplier (created today) po2 = await create_purchase_order( db, tenant_id, supplier_low_trust, PurchaseOrderStatus.pending_approval, Decimal("789.00"), created_offset_days=0, items_data=[ {"name": "Aceite de Oliva Virgen", "quantity": 30, "unit_price": 8.50, "uom": "l"}, {"name": "Azúcar Moreno", "quantity": 50, "unit_price": 1.80, "uom": "kg"} ] ) pos_created.append(po2) # 3. PENDING_APPROVAL - URGENT: Critical stock for tomorrow's Croissant production po3 = await create_purchase_order( db, tenant_id, supplier_high_trust, PurchaseOrderStatus.pending_approval, Decimal("450.00"), created_offset_days=0, priority="urgent", items_data=[ {"name": "Harina de Trigo T55", "quantity": 100, "unit_price": 0.85, "uom": "kg"}, {"name": "Mantequilla sin Sal 82% MG", "quantity": 30, "unit_price": 6.50, "uom": "kg"}, {"name": "Huevos Frescos Categoría A", "quantity": 200, "unit_price": 0.25, "uom": "unidad"} ] ) pos_created.append(po3) # 4. APPROVED (auto-approved, small amount, trusted supplier) po4 = await create_purchase_order( db, tenant_id, supplier_high_trust, PurchaseOrderStatus.approved, Decimal("234.50"), created_offset_days=0, auto_approved=True, items_data=[ {"name": "Levadura Seca", "quantity": 5, "unit_price": 6.90, "uom": "kg"}, {"name": "Sal Fina", "quantity": 25, "unit_price": 0.85, "uom": "kg"} ] ) pos_created.append(po4) # 5. APPROVED (manually approved yesterday) po5 = await create_purchase_order( db, tenant_id, supplier_high_trust, PurchaseOrderStatus.approved, Decimal("456.78"), created_offset_days=-1, items_data=[ {"name": "Bolsas de Papel Kraft", "quantity": 1000, "unit_price": 0.12, "uom": "unidad"}, {"name": "Cajas de Cartón Grande", "quantity": 200, "unit_price": 0.45, "uom": "unidad"} ] ) pos_created.append(po5) # 6. COMPLETED (delivered last week) po6 = await create_purchase_order( db, tenant_id, supplier_high_trust, PurchaseOrderStatus.completed, Decimal("1567.80"), created_offset_days=-7, items_data=[ {"name": "Harina T55 Premium", "quantity": 300, "unit_price": 0.90, "uom": "kg"}, {"name": "Chocolate Negro 70%", "quantity": 40, "unit_price": 7.80, "uom": "kg"} ] ) pos_created.append(po6) # 7. COMPLETED (delivered 5 days ago) po7 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.completed, Decimal("890.45"), created_offset_days=-5, items_data=[ {"name": "Nueces Peladas", "quantity": 20, "unit_price": 12.50, "uom": "kg"}, {"name": "Pasas Sultanas", "quantity": 15, "unit_price": 4.30, "uom": "kg"} ] ) pos_created.append(po7) # 8. CANCELLED (supplier unavailable) po8 = await create_purchase_order( db, tenant_id, supplier_low_trust, PurchaseOrderStatus.cancelled, Decimal("345.00"), created_offset_days=-3, items_data=[ {"name": "Avellanas Tostadas", "quantity": 25, "unit_price": 11.80, "uom": "kg"} ] ) po8.rejection_reason = "Supplier unable to deliver - stock unavailable" po8.notes = "Cancelled: Supplier stock unavailable at required delivery date" pos_created.append(po8) # 9. DISPUTED (quality issues) po9 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.disputed, Decimal("678.90"), created_offset_days=-4, priority="high", items_data=[ {"name": "Cacao en Polvo", "quantity": 30, "unit_price": 18.50, "uom": "kg"}, {"name": "Vainilla en Rama", "quantity": 2, "unit_price": 45.20, "uom": "kg"} ] ) po9.rejection_reason = "Quality below specifications - requesting replacement" po9.notes = "DISPUTED: Quality issue reported - batch rejected, requesting replacement or refund" pos_created.append(po9) await db.commit() logger.info( f"Successfully created {len(pos_created)} purchase orders for tenant", tenant_id=str(tenant_id), pending_approval=3, approved=2, completed=2, cancelled=1, disputed=1 ) return pos_created async def seed_all(db: AsyncSession): """Seed all demo tenants with purchase orders""" logger.info("Starting demo purchase orders seed process") all_pos = [] for tenant_id in DEMO_TENANT_IDS: # Check if POs already exist result = await db.execute( select(PurchaseOrder).where(PurchaseOrder.tenant_id == tenant_id).limit(1) ) existing = result.scalar_one_or_none() if existing: logger.info(f"Purchase orders already exist for tenant {tenant_id}, skipping") continue pos = await seed_purchase_orders_for_tenant(db, tenant_id) all_pos.extend(pos) return { "total_pos_created": len(all_pos), "tenants_seeded": len(DEMO_TENANT_IDS), "status": "completed" } async def main(): """Main execution function""" # Get database URL from environment database_url = os.getenv("PROCUREMENT_DATABASE_URL") if not database_url: logger.error("PROCUREMENT_DATABASE_URL environment variable must be set") return 1 # Ensure asyncpg driver if database_url.startswith("postgresql://"): database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) # Create async engine engine = create_async_engine(database_url, echo=False) async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) try: async with async_session() as session: result = await seed_all(session) logger.info( "Purchase orders seed completed successfully!", total_pos=result["total_pos_created"], tenants=result["tenants_seeded"] ) # Print summary print("\n" + "="*60) print("DEMO PURCHASE ORDERS SEED SUMMARY") print("="*60) print(f"Total POs Created: {result['total_pos_created']}") print(f"Tenants Seeded: {result['tenants_seeded']}") print("\nPO Distribution:") print(" - 3 PENDING_APPROVAL (need user action)") print(" - 2 APPROVED (in progress)") print(" - 2 COMPLETED (delivered)") print(" - 1 CANCELLED (supplier issue)") print(" - 1 DISPUTED (quality issue)") print("="*60 + "\n") return 0 except Exception as e: logger.error(f"Purchase orders seed failed: {str(e)}", exc_info=True) return 1 finally: await engine.dispose() if __name__ == "__main__": exit_code = asyncio.run(main()) sys.exit(exit_code)