#!/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 ) from shared.utils.demo_dates import BASE_REFERENCE_DATE from shared.messaging import RabbitMQClient # Configure logging logger = structlog.get_logger() # Demo tenant IDs (match those from tenant service) DEMO_TENANT_IDS = [ uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"), # Professional Bakery (standalone) uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8"), # Enterprise Chain (parent) uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9"), # Enterprise Child 1 (Madrid) uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0"), # Enterprise Child 2 (Barcelona) uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1"), # Enterprise Child 3 (Valencia) ] # 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 = BASE_REFERENCE_DATE + 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-{BASE_REFERENCE_DATE.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.error(f"Failed to generate reasoning_data, falling back to basic reasoning: {e}") logger.exception(e) # Fallback: Always generate basic reasoning_data to ensure it exists try: # Get product names from items_data as fallback items_list = items_data or [] 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"] # Create basic low stock reasoning as fallback reasoning_data = create_po_reasoning_low_stock( supplier_name=supplier.name, product_names=product_names, current_stock=25.0, # Default simulated current stock required_stock=100.0, # Default required stock days_until_stockout=3, # Default days until stockout threshold_percentage=20, affected_products=product_names[:2] # First 2 products affected ) logger.info("Successfully generated fallback reasoning_data") except Exception as fallback_error: logger.error(f"Fallback reasoning generation also failed: {fallback_error}") # Ultimate fallback: Create minimal valid reasoning data structure reasoning_data = { "type": "low_stock_detection", "parameters": { "supplier_name": supplier.name, "product_names": ["Demo Product"], "product_count": 1, "current_stock": 10.0, "required_stock": 50.0, "days_until_stockout": 2 }, "consequence": { "type": "stockout_risk", "severity": "medium", "impact_days": 2 }, "metadata": { "trigger_source": "demo_fallback", "ai_assisted": False } } logger.info("Used ultimate fallback reasoning_data structure") # 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) # ============================================================================ # DASHBOARD SHOWCASE SCENARIOS - These create specific alert conditions # ============================================================================ # 10. PO APPROVAL ESCALATION - Pending for 72+ hours (URGENT dashboard alert) po10 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.pending_approval, Decimal("450.00"), created_offset_days=-3, # Created 3 days (72 hours) ago priority="high", items_data=[ {"name": "Levadura Seca", "quantity": 50, "unit_price": 6.90, "uom": "kg"}, {"name": "Sal Fina", "quantity": 30, "unit_price": 0.85, "uom": "kg"} ] ) # Note: Manual notes removed to reflect real orchestrator behavior pos_created.append(po10) # 11. DELIVERY OVERDUE - Expected delivery is 4 hours late (URGENT dashboard alert) delivery_overdue_time = BASE_REFERENCE_DATE - timedelta(hours=4) po11 = await create_purchase_order( db, tenant_id, supplier_high_trust, PurchaseOrderStatus.sent_to_supplier, Decimal("850.00"), created_offset_days=-5, items_data=[ {"name": "Harina de Trigo T55", "quantity": 500, "unit_price": 0.85, "uom": "kg"}, {"name": "Mantequilla sin Sal 82% MG", "quantity": 50, "unit_price": 6.50, "uom": "kg"} ] ) # Override delivery date to be 4 hours ago (overdue) po11.required_delivery_date = delivery_overdue_time po11.expected_delivery_date = delivery_overdue_time pos_created.append(po11) # 12. DELIVERY ARRIVING SOON - Arriving in 8 hours (TODAY dashboard alert) arriving_soon_time = BASE_REFERENCE_DATE + timedelta(hours=8) po12 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.sent_to_supplier, Decimal("675.50"), created_offset_days=-2, items_data=[ {"name": "Azúcar Moreno", "quantity": 100, "unit_price": 1.80, "uom": "kg"}, {"name": "Aceite de Oliva Virgen", "quantity": 50, "unit_price": 8.50, "uom": "l"}, {"name": "Miel de Azahar", "quantity": 15, "unit_price": 8.90, "uom": "kg"} ] ) # Override delivery date to be in 8 hours po12.expected_delivery_date = arriving_soon_time po12.required_delivery_date = arriving_soon_time pos_created.append(po12) # 13. DELIVERY TODAY MORNING - Scheduled for 10 AM today delivery_today_morning = BASE_REFERENCE_DATE.replace(hour=10, minute=0, second=0, microsecond=0) po13 = await create_purchase_order( db, tenant_id, supplier_high_trust, PurchaseOrderStatus.sent_to_supplier, Decimal("625.00"), created_offset_days=-3, items_data=[ {"name": "Harina de Trigo T55", "quantity": 500, "unit_price": 0.85, "uom": "kg"}, {"name": "Levadura Fresca", "quantity": 25, "unit_price": 8.00, "uom": "kg"} ] ) po13.expected_delivery_date = delivery_today_morning po13.required_delivery_date = delivery_today_morning pos_created.append(po13) # 14. DELIVERY TODAY AFTERNOON - Scheduled for 3 PM today delivery_today_afternoon = BASE_REFERENCE_DATE.replace(hour=15, minute=0, second=0, microsecond=0) po14 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.confirmed, Decimal("380.50"), created_offset_days=-2, items_data=[ {"name": "Papel Kraft Bolsas", "quantity": 5000, "unit_price": 0.05, "uom": "unit"}, {"name": "Cajas Pastelería", "quantity": 500, "unit_price": 0.26, "uom": "unit"} ] ) po14.expected_delivery_date = delivery_today_afternoon po14.required_delivery_date = delivery_today_afternoon pos_created.append(po14) # 15. DELIVERY TOMORROW EARLY - Scheduled for 8 AM tomorrow (high priority) delivery_tomorrow_early = BASE_REFERENCE_DATE + timedelta(days=1, hours=8) po15 = await create_purchase_order( db, tenant_id, supplier_high_trust, PurchaseOrderStatus.approved, Decimal("445.00"), created_offset_days=-1, priority="high", items_data=[ {"name": "Harina Integral", "quantity": 300, "unit_price": 0.95, "uom": "kg"}, {"name": "Sal Marina", "quantity": 50, "unit_price": 1.60, "uom": "kg"} ] ) po15.expected_delivery_date = delivery_tomorrow_early po15.required_delivery_date = delivery_tomorrow_early pos_created.append(po15) # 16. DELIVERY TOMORROW LATE - Scheduled for 5 PM tomorrow delivery_tomorrow_late = BASE_REFERENCE_DATE + timedelta(days=1, hours=17) po16 = await create_purchase_order( db, tenant_id, supplier_low_trust, PurchaseOrderStatus.sent_to_supplier, Decimal("890.00"), created_offset_days=-2, items_data=[ {"name": "Chocolate Negro 70%", "quantity": 80, "unit_price": 8.50, "uom": "kg"}, {"name": "Cacao en Polvo", "quantity": 30, "unit_price": 7.00, "uom": "kg"} ] ) po16.expected_delivery_date = delivery_tomorrow_late po16.required_delivery_date = delivery_tomorrow_late pos_created.append(po16) # 17. DELIVERY DAY AFTER - Scheduled for 11 AM in 2 days delivery_day_after = BASE_REFERENCE_DATE + timedelta(days=2, hours=11) po17 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.confirmed, Decimal("520.00"), created_offset_days=-1, items_data=[ {"name": "Nata 35% MG", "quantity": 100, "unit_price": 3.80, "uom": "l"}, {"name": "Queso Crema", "quantity": 40, "unit_price": 3.50, "uom": "kg"} ] ) po17.expected_delivery_date = delivery_day_after po17.required_delivery_date = delivery_day_after pos_created.append(po17) # 18. DELIVERY THIS WEEK - Scheduled for 2 PM in 4 days delivery_this_week = BASE_REFERENCE_DATE + timedelta(days=4, hours=14) po18 = await create_purchase_order( db, tenant_id, supplier_low_trust, PurchaseOrderStatus.approved, Decimal("675.50"), created_offset_days=-1, items_data=[ {"name": "Miel de Azahar", "quantity": 50, "unit_price": 8.90, "uom": "kg"}, {"name": "Almendras Marcona", "quantity": 40, "unit_price": 9.50, "uom": "kg"}, {"name": "Nueces", "quantity": 30, "unit_price": 7.20, "uom": "kg"} ] ) po18.expected_delivery_date = delivery_this_week po18.required_delivery_date = delivery_this_week pos_created.append(po18) await db.commit() logger.info( f"Successfully created {len(pos_created)} purchase orders for tenant", tenant_id=str(tenant_id), pending_approval=4, # Updated count (includes escalated PO) approved=3, # PO #15, #18 + 1 regular completed=2, sent_to_supplier=4, # PO #11, #12, #13, #16 confirmed=3, # PO #14, #17 + 1 regular cancelled=1, disputed=1, delivery_showcase=9 # POs #11-18 with delivery tracking ) return pos_created async def seed_internal_transfer_pos_for_child( db: AsyncSession, child_tenant_id: uuid.UUID, parent_tenant_id: uuid.UUID, child_name: str ) -> List[PurchaseOrder]: """ Seed internal transfer purchase orders from child to parent tenant These are POs where: - tenant_id = child (the requesting outlet) - supplier_id = parent (the supplier) - is_internal = True - transfer_type = 'finished_goods' """ logger.info( "Seeding internal transfer POs for child tenant", child_tenant_id=str(child_tenant_id), parent_tenant_id=str(parent_tenant_id), child_name=child_name ) internal_pos = [] # Create 5-7 internal transfer POs per child for realistic history num_transfers = random.randint(5, 7) # Common finished goods that children request from parent finished_goods_items = [ [ {"name": "Baguette Tradicional", "quantity": 50, "unit_price": 1.20, "uom": "unidad"}, {"name": "Pan de Molde Integral", "quantity": 30, "unit_price": 2.50, "uom": "unidad"}, ], [ {"name": "Croissant Mantequilla", "quantity": 40, "unit_price": 1.80, "uom": "unidad"}, {"name": "Napolitana Chocolate", "quantity": 25, "unit_price": 2.00, "uom": "unidad"}, ], [ {"name": "Pan de Masa Madre", "quantity": 20, "unit_price": 3.50, "uom": "unidad"}, {"name": "Pan Rústico", "quantity": 30, "unit_price": 2.80, "uom": "unidad"}, ], [ {"name": "Ensaimada", "quantity": 15, "unit_price": 3.20, "uom": "unidad"}, {"name": "Palmera", "quantity": 20, "unit_price": 2.50, "uom": "unidad"}, ], [ {"name": "Bollo Suizo", "quantity": 30, "unit_price": 1.50, "uom": "unidad"}, {"name": "Donut Glaseado", "quantity": 25, "unit_price": 1.80, "uom": "unidad"}, ] ] for i in range(num_transfers): # Vary creation dates: some recent, some from past weeks created_offset = -random.randint(0, 21) # Last 3 weeks # Select items for this transfer items = finished_goods_items[i % len(finished_goods_items)] # Calculate total total_amount = sum(Decimal(str(item["quantity"] * item["unit_price"])) for item in items) # Vary status: most completed, some in progress if i < num_transfers - 2: status = PurchaseOrderStatus.completed elif i == num_transfers - 2: status = PurchaseOrderStatus.approved else: status = PurchaseOrderStatus.pending_approval created_at = BASE_REFERENCE_DATE + timedelta(days=created_offset) # Generate unique internal transfer PO number while True: po_number = f"INT-{child_name[:3].upper()}-{random.randint(1000, 9999)}" existing_po = await db.execute( select(PurchaseOrder).where(PurchaseOrder.po_number == po_number).limit(1) ) if not existing_po.scalar_one_or_none(): break # Delivery typically 2-3 days for internal transfers required_delivery = created_at + timedelta(days=random.randint(2, 3)) # Create internal transfer PO po = PurchaseOrder( tenant_id=child_tenant_id, # PO belongs to child supplier_id=parent_tenant_id, # Parent is the "supplier" po_number=po_number, status=status, is_internal=True, # CRITICAL: Mark as internal transfer source_tenant_id=parent_tenant_id, # Source is parent destination_tenant_id=child_tenant_id, # Destination is child transfer_type="finished_goods", # Transfer finished products subtotal=total_amount, tax_amount=Decimal("0.00"), # No tax on internal transfers shipping_cost=Decimal("0.00"), # No shipping cost for internal total_amount=total_amount, required_delivery_date=required_delivery, expected_delivery_date=required_delivery if status != PurchaseOrderStatus.pending_approval else None, notes=f"Internal transfer request from {child_name} outlet", created_at=created_at, updated_at=created_at, created_by=SYSTEM_USER_ID, updated_by=SYSTEM_USER_ID ) if status == PurchaseOrderStatus.completed: po.approved_at = created_at + timedelta(hours=2) po.sent_to_supplier_at = created_at + timedelta(hours=3) po.delivered_at = required_delivery po.completed_at = required_delivery db.add(po) await db.flush() # Get PO ID # Add items for item_data in items: item = PurchaseOrderItem( purchase_order_id=po.id, tenant_id=child_tenant_id, # Set tenant_id for the item inventory_product_id=uuid.uuid4(), # Would link to actual inventory items product_name=item_data["name"], ordered_quantity=Decimal(str(item_data["quantity"])), unit_price=Decimal(str(item_data["unit_price"])), unit_of_measure=item_data["uom"], line_total=Decimal(str(item_data["quantity"] * item_data["unit_price"])) ) db.add(item) internal_pos.append(po) await db.commit() logger.info( f"Successfully created {len(internal_pos)} internal transfer POs", child_tenant_id=str(child_tenant_id), child_name=child_name ) return internal_pos async def seed_all(db: AsyncSession): """Seed all demo tenants with purchase orders""" logger.info("Starting demo purchase orders seed process") all_pos = [] # Enterprise parent and children IDs ENTERPRISE_PARENT = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") ENTERPRISE_CHILDREN = [ (uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9"), "Madrid Centro"), (uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0"), "Barcelona Gràcia"), (uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1"), "Valencia Ruzafa"), ] 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 # Seed regular external POs for all tenants pos = await seed_purchase_orders_for_tenant(db, tenant_id) all_pos.extend(pos) # Additionally, seed internal transfer POs for enterprise children for child_id, child_name in ENTERPRISE_CHILDREN: if tenant_id == child_id: internal_pos = await seed_internal_transfer_pos_for_child( db, child_id, ENTERPRISE_PARENT, child_name ) all_pos.extend(internal_pos) logger.info( f"Added {len(internal_pos)} internal transfer POs for {child_name}", child_id=str(child_id) ) return { "total_pos_created": len(all_pos), "tenants_seeded": len(DEMO_TENANT_IDS), "internal_transfers_created": sum( 1 for child_id, _ in ENTERPRISE_CHILDREN if any(po.tenant_id == child_id and po.is_internal for po in all_pos) ), "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)