Files
bakery-ia/services/procurement/scripts/demo/seed_demo_purchase_orders.py
2025-11-30 09:12:40 +01:00

905 lines
35 KiB
Python

#!/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
# 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.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)
# ============================================================================
# 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"}
]
)
po10.notes = "⚠️ ESCALATED: Pending approval for 72+ hours - Production batch depends on tomorrow morning delivery"
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
po11.notes = "🔴 OVERDUE: Expected delivery was 4 hours ago - Contact supplier immediately"
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
po12.notes = "📦 ARRIVING SOON: Delivery expected in 8 hours - Prepare for stock receipt"
pos_created.append(po12)
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=2,
completed=2,
sent_to_supplier=2, # Overdue + arriving soon
cancelled=1,
disputed=1,
dashboard_showcase=3 # New POs specifically for dashboard alerts
)
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)