Consolidated incremental migrations into single unified initial schema files for both procurement and production services. This simplifies database setup and eliminates migration chain complexity. Changes: - Procurement: Merged 3 migrations into 001_unified_initial_schema.py - Initial schema (20251015_1229) - Add supplier_price_list_id (20251030_0737) - Add JTBD reasoning fields (20251107) - Production: Merged 3 migrations into 001_unified_initial_schema.py - Initial schema (20251015_1231) - Add waste tracking fields (20251023_0900) - Add JTBD reasoning fields (20251107) All new fields (reasoning, consequence, reasoning_data, waste_defect_type, is_ai_assisted, supplier_price_list_id) are now included in the initial schemas from the start. Updated model files to use deferred() for reasoning fields to prevent breaking queries when running against existing databases.
469 lines
17 KiB
Python
469 lines
17 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
|
|
|
|
# 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
|
|
)
|
|
|
|
# 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
|
|
reasoning_text = None
|
|
reasoning_json = None
|
|
consequence_text = None
|
|
|
|
try:
|
|
# Try to set reasoning fields (will work after migration)
|
|
if status == PurchaseOrderStatus.pending_approval:
|
|
reasoning_text = f"Low stock detected for {supplier.name} items. Current inventory projected to run out in {days_until_delivery + 2} days."
|
|
consequence_text = f"Stock-out risk in {days_until_delivery + 2} days if not approved. Production may be impacted."
|
|
reasoning_json = {
|
|
"trigger": "low_stock",
|
|
"urgency_score": 75 if days_until_delivery < 5 else 50,
|
|
"days_remaining": days_until_delivery + 2,
|
|
"supplier_trust_score": supplier.trust_score
|
|
}
|
|
elif auto_approved:
|
|
reasoning_text = f"Auto-approved based on supplier trust score ({supplier.trust_score:.0%}) and amount within threshold (€{subtotal:.2f})."
|
|
reasoning_json = {
|
|
"trigger": "auto_approval",
|
|
"trust_score": supplier.trust_score,
|
|
"amount": float(subtotal),
|
|
"threshold": 500.0
|
|
}
|
|
except Exception:
|
|
# Columns don't exist yet, that's ok
|
|
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 reasoning fields if they exist (after migration)
|
|
if reasoning_text:
|
|
try:
|
|
po.reasoning = reasoning_text
|
|
po.consequence = consequence_text
|
|
po.reasoning_data = reasoning_json
|
|
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)
|