#!/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 # 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) ] 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 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 PO number po_number = f"PO-{datetime.now().year}-{random.randint(100, 999)}" # 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 for i18n support reasoning_data = None try: # Get product names from items product_names = [item.product_name for item in items if hasattr(item, 'product_name')] if not product_names: product_names = [f"Product {i+1}" for i in range(len(items))] if status == PurchaseOrderStatus.pending_approval: # Low stock detection reasoning days_until_stockout = days_until_delivery + 2 reasoning_data = create_po_reasoning_low_stock( supplier_name=supplier.name, product_names=product_names, current_stock=random.uniform(20, 50), # Demo: low stock required_stock=random.uniform(100, 200), # Demo: needed stock days_until_stockout=days_until_stockout, threshold_percentage=20, affected_products=product_names[:2] if len(product_names) > 1 else product_names, estimated_lost_orders=random.randint(5, 15) if days_until_stockout <= 3 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=f"Auto-approval threshold: €500", trust_score=float(supplier.trust_score) if hasattr(supplier, 'trust_score') else 0.85 ) except Exception as e: logger.warning(f"Failed to generate reasoning_data: {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 except Exception: pass # Columns don't 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 - Large amount (created yesterday) po3 = await create_purchase_order( db, tenant_id, supplier_medium_trust, PurchaseOrderStatus.pending_approval, Decimal("250.00"), created_offset_days=-1, priority="normal", items_data=[ {"name": "Harina de Fuerza T65", "quantity": 500, "unit_price": 0.95, "uom": "kg"}, {"name": "Mantequilla Premium", "quantity": 80, "unit_price": 5.20, "uom": "kg"}, {"name": "Huevos Categoría A", "quantity": 600, "unit_price": 0.22, "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)