demo seed change

This commit is contained in:
Urtzi Alfaro
2025-12-13 23:57:54 +01:00
parent f3688dfb04
commit ff830a3415
299 changed files with 20328 additions and 19485 deletions

View File

@@ -1,33 +1,25 @@
"""
Internal Demo Cloning API for Suppliers Service
Service-to-service endpoint for cloning supplier and procurement data
Service-to-service endpoint for cloning supplier data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from sqlalchemy import select, delete
import structlog
import uuid
from datetime import datetime, timezone, timedelta, date
from uuid import UUID
from datetime import datetime, timezone
from typing import Optional
import os
import sys
import json
from pathlib import Path
# Add shared path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from app.core.database import get_db
from app.models.suppliers import (
Supplier, SupplierPriceList, SupplierQualityReview,
SupplierStatus, QualityRating
)
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
from app.models.suppliers import Supplier
from app.core.config import settings
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
router = APIRouter()
# Base demo tenant IDs
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
@@ -41,7 +33,7 @@ def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
return True
@router.post("/clone")
@router.post("/internal/demo/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
@@ -54,252 +46,235 @@ async def clone_demo_data(
"""
Clone suppliers service data for a virtual demo tenant
Clones:
- Suppliers (vendor master data)
- Supplier price lists (product pricing)
- Quality reviews
This endpoint creates fresh demo data by:
1. Loading seed data from JSON files
2. Applying XOR-based ID transformation
3. Adjusting dates relative to session creation time
4. Creating records in the virtual tenant
Args:
base_tenant_id: Template tenant UUID to clone from
base_tenant_id: Template tenant UUID (for reference)
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
session_created_at: Session creation timestamp for date adjustment
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting suppliers data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting suppliers data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
# Load seed data from JSON files
try:
from shared.utils.seed_data_paths import get_seed_data_path
if demo_account_type == "professional":
json_file = get_seed_data_path("professional", "05-suppliers.json")
elif demo_account_type == "enterprise":
json_file = get_seed_data_path("enterprise", "05-suppliers.json")
else:
raise ValueError(f"Invalid demo account type: {demo_account_type}")
except ImportError:
# Fallback to original path
seed_data_dir = Path(__file__).parent.parent.parent.parent / "infrastructure" / "seed-data"
if demo_account_type == "professional":
json_file = seed_data_dir / "professional" / "05-suppliers.json"
elif demo_account_type == "enterprise":
json_file = seed_data_dir / "enterprise" / "parent" / "05-suppliers.json"
else:
raise ValueError(f"Invalid demo account type: {demo_account_type}")
if not json_file.exists():
raise HTTPException(
status_code=404,
detail=f"Seed data file not found: {json_file}"
)
# Load JSON data
with open(json_file, 'r', encoding='utf-8') as f:
seed_data = json.load(f)
# Track cloning statistics
stats = {
"suppliers": 0,
"price_lists": 0,
"quality_reviews": 0
"suppliers": 0
}
# ID mappings
supplier_id_map = {}
price_list_map = {}
# Create Suppliers
for supplier_data in seed_data.get('suppliers', []):
# Transform supplier ID using XOR
from shared.utils.demo_id_transformer import transform_id
try:
supplier_uuid = uuid.UUID(supplier_data['id'])
transformed_id = transform_id(supplier_data['id'], virtual_uuid)
except ValueError as e:
logger.error("Failed to parse supplier UUID",
supplier_id=supplier_data['id'],
error=str(e))
raise HTTPException(
status_code=400,
detail=f"Invalid UUID format in supplier data: {str(e)}"
)
# Clone Suppliers
result = await db.execute(
select(Supplier).where(Supplier.tenant_id == base_uuid)
)
base_suppliers = result.scalars().all()
# Adjust dates relative to session creation time
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
adjusted_created_at = adjust_date_for_demo(
datetime.fromisoformat(supplier_data['created_at'].replace('Z', '+00:00')),
session_time,
BASE_REFERENCE_DATE
)
# Handle optional updated_at field
if 'updated_at' in supplier_data:
adjusted_updated_at = adjust_date_for_demo(
datetime.fromisoformat(supplier_data['updated_at'].replace('Z', '+00:00')),
session_time,
BASE_REFERENCE_DATE
)
else:
adjusted_updated_at = adjusted_created_at
logger.info(
"Found suppliers to clone",
count=len(base_suppliers),
base_tenant=str(base_uuid)
)
# Map supplier_type to enum if it's a string
from app.models.suppliers import SupplierType, SupplierStatus, PaymentTerms
for supplier in base_suppliers:
new_supplier_id = uuid.uuid4()
supplier_id_map[supplier.id] = new_supplier_id
supplier_type_value = supplier_data.get('supplier_type')
if supplier_type_value is None:
# Default to multi if supplier_type not provided
supplier_type_value = SupplierType.multi
elif isinstance(supplier_type_value, str):
try:
supplier_type_value = SupplierType[supplier_type_value]
except KeyError:
supplier_type_value = SupplierType.multi
# Map payment_terms to enum if it's a string
payment_terms_value = supplier_data.get('payment_terms', 'net_30')
if isinstance(payment_terms_value, str):
try:
payment_terms_value = PaymentTerms[payment_terms_value]
except KeyError:
payment_terms_value = PaymentTerms.net_30
# Map status to enum if provided
status_value = supplier_data.get('status', 'active')
if isinstance(status_value, str):
try:
status_value = SupplierStatus[status_value]
except KeyError:
status_value = SupplierStatus.active
# Map created_by and updated_by - use a system user UUID if not provided
system_user_id = uuid.UUID('00000000-0000-0000-0000-000000000000')
created_by = supplier_data.get('created_by', str(system_user_id))
updated_by = supplier_data.get('updated_by', str(system_user_id))
new_supplier = Supplier(
id=new_supplier_id,
id=str(transformed_id),
tenant_id=virtual_uuid,
name=supplier.name,
supplier_code=f"SUPP-{uuid.uuid4().hex[:6].upper()}", # New code
tax_id=supplier.tax_id,
registration_number=supplier.registration_number,
supplier_type=supplier.supplier_type,
status=supplier.status,
contact_person=supplier.contact_person,
email=supplier.email,
phone=supplier.phone,
mobile=supplier.mobile,
website=supplier.website,
address_line1=supplier.address_line1,
address_line2=supplier.address_line2,
city=supplier.city,
state_province=supplier.state_province,
postal_code=supplier.postal_code,
country=supplier.country,
payment_terms=supplier.payment_terms,
credit_limit=supplier.credit_limit,
currency=supplier.currency,
standard_lead_time=supplier.standard_lead_time,
minimum_order_amount=supplier.minimum_order_amount,
delivery_area=supplier.delivery_area,
quality_rating=supplier.quality_rating,
delivery_rating=supplier.delivery_rating,
total_orders=supplier.total_orders,
total_amount=supplier.total_amount,
approved_by=supplier.approved_by,
approved_at=supplier.approved_at,
rejection_reason=supplier.rejection_reason,
notes=supplier.notes,
certifications=supplier.certifications,
business_hours=supplier.business_hours,
specializations=supplier.specializations,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=supplier.created_by,
updated_by=supplier.updated_by
name=supplier_data['name'],
supplier_code=supplier_data.get('supplier_code'),
tax_id=supplier_data.get('tax_id'),
registration_number=supplier_data.get('registration_number'),
supplier_type=supplier_type_value,
status=status_value,
contact_person=supplier_data.get('contact_person'),
email=supplier_data.get('email'),
phone=supplier_data.get('phone'),
mobile=supplier_data.get('mobile'),
website=supplier_data.get('website'),
address_line1=supplier_data.get('address_line1'),
address_line2=supplier_data.get('address_line2'),
city=supplier_data.get('city'),
state_province=supplier_data.get('state_province'),
postal_code=supplier_data.get('postal_code'),
country=supplier_data.get('country'),
payment_terms=payment_terms_value,
credit_limit=supplier_data.get('credit_limit', 0.0),
currency=supplier_data.get('currency', 'EUR'),
standard_lead_time=supplier_data.get('standard_lead_time', 3),
minimum_order_amount=supplier_data.get('minimum_order_amount'),
delivery_area=supplier_data.get('delivery_area'),
quality_rating=supplier_data.get('quality_rating', 0.0),
delivery_rating=supplier_data.get('delivery_rating', 0.0),
total_orders=supplier_data.get('total_orders', 0),
total_amount=supplier_data.get('total_amount', 0.0),
trust_score=supplier_data.get('trust_score', 0.0),
is_preferred_supplier=supplier_data.get('is_preferred_supplier', False),
auto_approve_enabled=supplier_data.get('auto_approve_enabled', False),
total_pos_count=supplier_data.get('total_pos_count', 0),
approved_pos_count=supplier_data.get('approved_pos_count', 0),
on_time_delivery_rate=supplier_data.get('on_time_delivery_rate', 0.0),
fulfillment_rate=supplier_data.get('fulfillment_rate', 0.0),
last_performance_update=adjust_date_for_demo(
datetime.fromisoformat(supplier_data['last_performance_update'].replace('Z', '+00:00')),
session_time,
BASE_REFERENCE_DATE
) if supplier_data.get('last_performance_update') else None,
approved_by=supplier_data.get('approved_by'),
approved_at=adjust_date_for_demo(
datetime.fromisoformat(supplier_data['approved_at'].replace('Z', '+00:00')),
session_time,
BASE_REFERENCE_DATE
) if supplier_data.get('approved_at') else None,
rejection_reason=supplier_data.get('rejection_reason'),
notes=supplier_data.get('notes'),
certifications=supplier_data.get('certifications'),
business_hours=supplier_data.get('business_hours'),
specializations=supplier_data.get('specializations'),
created_at=adjusted_created_at,
updated_at=adjusted_updated_at,
created_by=created_by,
updated_by=updated_by
)
db.add(new_supplier)
stats["suppliers"] += 1
# Flush to get supplier IDs
await db.flush()
# Clone Supplier Price Lists
for old_supplier_id, new_supplier_id in supplier_id_map.items():
result = await db.execute(
select(SupplierPriceList).where(SupplierPriceList.supplier_id == old_supplier_id)
)
price_lists = result.scalars().all()
for price_list in price_lists:
new_price_id = uuid.uuid4()
price_list_map[price_list.id] = new_price_id
# Transform inventory_product_id to match virtual tenant's ingredient IDs
# Using same formula as inventory service: tenant_int ^ base_int
base_product_int = int(price_list.inventory_product_id.hex, 16)
virtual_tenant_int = int(virtual_uuid.hex, 16)
base_tenant_int = int(base_uuid.hex, 16)
# Reverse the original XOR to get the base ingredient ID
# base_product = base_tenant ^ base_ingredient_id
# So: base_ingredient_id = base_tenant ^ base_product
base_ingredient_int = base_tenant_int ^ base_product_int
# Now apply virtual tenant XOR
new_product_id = uuid.UUID(int=virtual_tenant_int ^ base_ingredient_int)
logger.debug(
"Transforming price list product ID using XOR",
supplier_name=supplier.name,
base_product_id=str(price_list.inventory_product_id),
new_product_id=str(new_product_id),
product_code=price_list.product_code
)
new_price_list = SupplierPriceList(
id=new_price_id,
tenant_id=virtual_uuid,
supplier_id=new_supplier_id,
inventory_product_id=new_product_id, # Transformed for virtual tenant
product_code=price_list.product_code,
unit_price=price_list.unit_price,
unit_of_measure=price_list.unit_of_measure,
minimum_order_quantity=price_list.minimum_order_quantity,
price_per_unit=price_list.price_per_unit,
tier_pricing=price_list.tier_pricing,
effective_date=price_list.effective_date,
expiry_date=price_list.expiry_date,
is_active=price_list.is_active,
brand=price_list.brand,
packaging_size=price_list.packaging_size,
origin_country=price_list.origin_country,
shelf_life_days=price_list.shelf_life_days,
storage_requirements=price_list.storage_requirements,
quality_specs=price_list.quality_specs,
allergens=price_list.allergens,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=price_list.created_by,
updated_by=price_list.updated_by
)
db.add(new_price_list)
stats["price_lists"] += 1
# Flush to get price list IDs
await db.flush()
# Clone Quality Reviews
result = await db.execute(
select(SupplierQualityReview).where(SupplierQualityReview.tenant_id == base_uuid)
)
base_reviews = result.scalars().all()
for review in base_reviews:
new_supplier_id = supplier_id_map.get(review.supplier_id, review.supplier_id)
# Adjust dates relative to session creation time
adjusted_review_date = adjust_date_for_demo(
review.review_date, session_time, BASE_REFERENCE_DATE
)
adjusted_follow_up_date = adjust_date_for_demo(
review.follow_up_date, session_time, BASE_REFERENCE_DATE
)
new_review = SupplierQualityReview(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
supplier_id=new_supplier_id,
review_date=adjusted_review_date,
review_type=review.review_type,
quality_rating=review.quality_rating,
delivery_rating=review.delivery_rating,
communication_rating=review.communication_rating,
overall_rating=review.overall_rating,
quality_comments=review.quality_comments,
delivery_comments=review.delivery_comments,
communication_comments=review.communication_comments,
improvement_suggestions=review.improvement_suggestions,
quality_issues=review.quality_issues,
corrective_actions=review.corrective_actions,
follow_up_required=review.follow_up_required,
follow_up_date=adjusted_follow_up_date,
is_final=review.is_final,
approved_by=review.approved_by,
created_at=session_time,
reviewed_by=review.reviewed_by
)
db.add(new_review)
stats["quality_reviews"] += 1
# Commit all changes
await db.commit()
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Suppliers data cloning completed",
"Suppliers data cloned successfully",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
suppliers_cloned=stats["suppliers"],
duration_ms=duration_ms
)
return {
"service": "suppliers",
"status": "completed",
"records_cloned": total_records,
"records_cloned": stats["suppliers"],
"duration_ms": duration_ms,
"details": stats
"details": {
"suppliers": stats["suppliers"],
"virtual_tenant_id": str(virtual_tenant_id)
}
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
@@ -336,45 +311,58 @@ async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
async def delete_demo_tenant_data(
virtual_tenant_id: UUID,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""Delete all supplier data for a virtual demo tenant"""
logger.info("Deleting supplier data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
start_time = datetime.now(timezone.utc)
"""
Delete all demo data for a virtual tenant.
This endpoint is idempotent - safe to call multiple times.
"""
start_time = datetime.now()
records_deleted = {
"suppliers": 0,
"total": 0
}
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Delete suppliers
result = await db.execute(
delete(Supplier)
.where(Supplier.tenant_id == virtual_tenant_id)
)
records_deleted["suppliers"] = result.rowcount
# Count records
supplier_count = await db.scalar(select(func.count(Supplier.id)).where(Supplier.tenant_id == virtual_uuid))
price_list_count = await db.scalar(select(func.count(SupplierPriceList.id)).where(SupplierPriceList.tenant_id == virtual_uuid))
quality_review_count = await db.scalar(select(func.count(SupplierQualityReview.id)).where(SupplierQualityReview.tenant_id == virtual_uuid))
records_deleted["total"] = records_deleted["suppliers"]
# Delete in order (child tables first)
await db.execute(delete(SupplierQualityReview).where(SupplierQualityReview.tenant_id == virtual_uuid))
await db.execute(delete(SupplierPriceList).where(SupplierPriceList.tenant_id == virtual_uuid))
await db.execute(delete(Supplier).where(Supplier.tenant_id == virtual_uuid))
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info("Supplier data deleted successfully", virtual_tenant_id=virtual_tenant_id, duration_ms=duration_ms)
logger.info(
"demo_data_deleted",
service="suppliers",
virtual_tenant_id=str(virtual_tenant_id),
records_deleted=records_deleted
)
return {
"service": "suppliers",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"suppliers": supplier_count,
"price_lists": price_list_count,
"quality_reviews": quality_review_count,
"total": supplier_count + price_list_count + quality_review_count
},
"duration_ms": duration_ms
"virtual_tenant_id": str(virtual_tenant_id),
"records_deleted": records_deleted,
"duration_ms": int((datetime.now() - start_time).total_seconds() * 1000)
}
except Exception as e:
logger.error("Failed to delete supplier data", error=str(e), exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))
logger.error(
"demo_data_deletion_failed",
service="suppliers",
virtual_tenant_id=str(virtual_tenant_id),
error=str(e)
)
raise HTTPException(
status_code=500,
detail=f"Failed to delete demo data: {str(e)}"
)

View File

@@ -11,7 +11,7 @@ from app.core.database import database_manager
from shared.service_base import StandardFastAPIService
# Import API routers
from app.api import suppliers, supplier_operations, analytics, internal_demo, audit
from app.api import suppliers, supplier_operations, analytics, audit, internal_demo
# REMOVED: purchase_orders, deliveries - PO and delivery management moved to Procurement Service
# from app.api import purchase_orders, deliveries
@@ -109,7 +109,7 @@ service.add_router(audit.router) # /suppliers/audit-logs - must be FI
service.add_router(supplier_operations.router) # /suppliers/operations/...
service.add_router(analytics.router) # /suppliers/analytics/...
service.add_router(suppliers.router) # /suppliers/{supplier_id} - catch-all, must be last
service.add_router(internal_demo.router)
service.add_router(internal_demo.router, tags=["internal-demo"])
if __name__ == "__main__":

View File

@@ -1,446 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Suppliers Seeding Script for Suppliers Service
Creates realistic Spanish suppliers for demo template tenants using pre-defined UUIDs
This script runs as a Kubernetes init job inside the suppliers-service container.
It populates the template tenants with a comprehensive catalog of suppliers.
Usage:
python /app/scripts/demo/seed_demo_suppliers.py
Environment Variables Required:
SUPPLIERS_DATABASE_URL - PostgreSQL connection string for suppliers database
DEMO_MODE - Set to 'production' for production seeding
LOG_LEVEL - Logging level (default: INFO)
Note: No database lookups needed - all IDs are pre-defined in the JSON file
"""
import asyncio
import uuid
import sys
import os
import json
from datetime import datetime, timezone, timedelta
from pathlib import Path
import random
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, text
import structlog
from app.models.suppliers import (
Supplier, SupplierPriceList,
SupplierType, SupplierStatus, PaymentTerms
)
# Configure logging
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
# Hardcoded SKU to Ingredient ID mapping (no database lookups needed!)
INGREDIENT_ID_MAP = {
"HAR-T55-001": "10000000-0000-0000-0000-000000000001",
"HAR-T65-002": "10000000-0000-0000-0000-000000000002",
"HAR-FUE-003": "10000000-0000-0000-0000-000000000003",
"HAR-INT-004": "10000000-0000-0000-0000-000000000004",
"HAR-CEN-005": "10000000-0000-0000-0000-000000000005",
"HAR-ESP-006": "10000000-0000-0000-0000-000000000006",
"LAC-MAN-001": "10000000-0000-0000-0000-000000000011",
"LAC-LEC-002": "10000000-0000-0000-0000-000000000012",
"LAC-NAT-003": "10000000-0000-0000-0000-000000000013",
"LAC-HUE-004": "10000000-0000-0000-0000-000000000014",
"LEV-FRE-001": "10000000-0000-0000-0000-000000000021",
"LEV-SEC-002": "10000000-0000-0000-0000-000000000022",
"BAS-SAL-001": "10000000-0000-0000-0000-000000000031",
"BAS-AZU-002": "10000000-0000-0000-0000-000000000032",
"ESP-CHO-001": "10000000-0000-0000-0000-000000000041",
"ESP-ALM-002": "10000000-0000-0000-0000-000000000042",
"ESP-VAI-004": "10000000-0000-0000-0000-000000000044",
"ESP-CRE-005": "10000000-0000-0000-0000-000000000045",
}
# Ingredient costs (for price list generation)
INGREDIENT_COSTS = {
"HAR-T55-001": 0.85,
"HAR-T65-002": 0.95,
"HAR-FUE-003": 1.15,
"HAR-INT-004": 1.20,
"HAR-CEN-005": 1.30,
"HAR-ESP-006": 2.45,
"LAC-MAN-001": 6.50,
"LAC-LEC-002": 0.95,
"LAC-NAT-003": 3.20,
"LAC-HUE-004": 0.25,
"LEV-FRE-001": 4.80,
"LEV-SEC-002": 12.50,
"BAS-SAL-001": 0.60,
"BAS-AZU-002": 0.90,
"ESP-CHO-001": 15.50,
"ESP-ALM-002": 8.90,
"ESP-VAI-004": 3.50,
"ESP-CRE-005": 7.20,
}
def load_suppliers_data():
"""Load suppliers data from JSON file"""
# Look for data file in the same directory as this script
data_file = Path(__file__).parent / "proveedores_es.json"
if not data_file.exists():
raise FileNotFoundError(
f"Suppliers data file not found: {data_file}. "
"Make sure proveedores_es.json is in the same directory as this script."
)
logger.info("Loading suppliers data", file=str(data_file))
with open(data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
suppliers = data.get("proveedores", [])
logger.info(f"Loaded {len(suppliers)} suppliers from JSON")
return suppliers
async def seed_suppliers_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
suppliers_data: list
) -> dict:
"""
Seed suppliers for a specific tenant using pre-defined UUIDs
Args:
db: Database session
tenant_id: UUID of the tenant
tenant_name: Name of the tenant (for logging)
suppliers_data: List of supplier dictionaries with pre-defined IDs
Returns:
Dict with seeding statistics
"""
logger.info("" * 80)
logger.info(f"Seeding suppliers for: {tenant_name}")
logger.info(f"Tenant ID: {tenant_id}")
logger.info("" * 80)
created_suppliers = 0
skipped_suppliers = 0
created_price_lists = 0
for supplier_data in suppliers_data:
supplier_name = supplier_data["name"]
# Generate tenant-specific UUID by combining base UUID with tenant ID
base_supplier_id = uuid.UUID(supplier_data["id"])
tenant_int = int(tenant_id.hex, 16)
supplier_id = uuid.UUID(int=tenant_int ^ int(base_supplier_id.hex, 16))
# Check if supplier already exists (using tenant-specific ID)
result = await db.execute(
select(Supplier).where(
Supplier.tenant_id == tenant_id,
Supplier.id == supplier_id
)
)
existing_supplier = result.scalars().first()
if existing_supplier:
logger.debug(f" ⏭️ Supplier exists, ensuring price lists: {supplier_name}")
skipped_suppliers += 1
# Don't skip - continue to create/update price lists below
else:
# Parse enums
try:
supplier_type = SupplierType(supplier_data.get("supplier_type", "ingredients"))
except ValueError:
supplier_type = SupplierType.INGREDIENTS
try:
status = SupplierStatus(supplier_data.get("status", "active"))
except ValueError:
status = SupplierStatus.ACTIVE
try:
payment_terms = PaymentTerms(supplier_data.get("payment_terms", "net_30"))
except ValueError:
payment_terms = PaymentTerms.NET_30
# Create supplier with pre-defined ID
supplier = Supplier(
id=supplier_id,
tenant_id=tenant_id,
name=supplier_name,
supplier_code=f"SUP-{created_suppliers + 1:03d}",
supplier_type=supplier_type,
status=status,
tax_id=supplier_data.get("tax_id"),
contact_person=supplier_data.get("contact_person"),
email=supplier_data.get("email"),
phone=supplier_data.get("phone"),
mobile=supplier_data.get("mobile"),
website=supplier_data.get("website"),
address_line1=supplier_data.get("address_line1"),
address_line2=supplier_data.get("address_line2"),
city=supplier_data.get("city"),
state_province=supplier_data.get("state_province"),
postal_code=supplier_data.get("postal_code"),
country=supplier_data.get("country", "España"),
payment_terms=payment_terms,
credit_limit=Decimal(str(supplier_data.get("credit_limit", 0.0))),
standard_lead_time=supplier_data.get("standard_lead_time", 3),
quality_rating=supplier_data.get("quality_rating", 4.5),
delivery_rating=supplier_data.get("delivery_rating", 4.5),
notes=supplier_data.get("notes"),
certifications=supplier_data.get("certifications", []),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=uuid.UUID("00000000-0000-0000-0000-000000000000"), # System user
updated_by=uuid.UUID("00000000-0000-0000-0000-000000000000") # System user
)
db.add(supplier)
created_suppliers += 1
logger.debug(f" ✅ Created supplier: {supplier_name}")
# Create price lists for products using pre-defined ingredient IDs
products = supplier_data.get("products", [])
for product_sku in products:
# Get ingredient ID from hardcoded mapping (no DB lookup!)
ingredient_id_str = INGREDIENT_ID_MAP.get(product_sku)
if not ingredient_id_str:
logger.warning(f" ⚠️ Product SKU not in mapping: {product_sku}")
continue
# Generate tenant-specific ingredient ID (same as inventory seed)
base_ingredient_id = uuid.UUID(ingredient_id_str)
tenant_int = int(tenant_id.hex, 16)
ingredient_id = uuid.UUID(int=tenant_int ^ int(base_ingredient_id.hex, 16))
# Check if price list already exists
existing_price_list_result = await db.execute(
select(SupplierPriceList).where(
SupplierPriceList.tenant_id == tenant_id,
SupplierPriceList.supplier_id == supplier_id,
SupplierPriceList.inventory_product_id == ingredient_id
)
)
existing_price_list = existing_price_list_result.scalars().first()
if existing_price_list:
# Price list already exists, skip
continue
# Get base cost from hardcoded costs
base_cost = INGREDIENT_COSTS.get(product_sku, 1.0)
# Calculate supplier price (slightly vary from base cost)
price_variation = random.uniform(0.90, 1.10)
unit_price = Decimal(str(base_cost * price_variation))
# price_per_unit is same as unit_price for base quantity
price_per_unit = unit_price
price_list = SupplierPriceList(
id=uuid.uuid4(),
tenant_id=tenant_id,
supplier_id=supplier_id,
inventory_product_id=ingredient_id,
product_code=product_sku,
unit_price=unit_price,
price_per_unit=price_per_unit,
minimum_order_quantity=random.choice([1, 5, 10]),
unit_of_measure="kg",
effective_date=datetime.now(timezone.utc) - timedelta(days=90),
is_active=True,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=uuid.UUID("00000000-0000-0000-0000-000000000000"), # System user
updated_by=uuid.UUID("00000000-0000-0000-0000-000000000000") # System user
)
db.add(price_list)
created_price_lists += 1
# Commit all changes for this tenant
await db.commit()
logger.info(f" 📊 Suppliers: {created_suppliers}, Price Lists: {created_price_lists}")
logger.info("")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"suppliers_created": created_suppliers,
"suppliers_skipped": skipped_suppliers,
"price_lists_created": created_price_lists,
"total_suppliers": len(suppliers_data)
}
async def seed_suppliers(db: AsyncSession):
"""
Seed suppliers for all demo template tenants using pre-defined IDs
Args:
db: Database session
Returns:
Dict with overall seeding statistics
"""
logger.info("=" * 80)
logger.info("🚚 Starting Demo Suppliers Seeding")
logger.info("=" * 80)
# Load suppliers data once
try:
suppliers_data = load_suppliers_data()
except FileNotFoundError as e:
logger.error(str(e))
raise
results = []
# Seed for Professional Bakery (single location)
logger.info("")
result_professional = await seed_suppliers_for_tenant(
db,
DEMO_TENANT_PROFESSIONAL,
"Panadería Artesana Madrid (Professional)",
suppliers_data
)
results.append(result_professional)
# Seed for Enterprise Parent (central production - Obrador)
logger.info("")
result_enterprise_parent = await seed_suppliers_for_tenant(
db,
DEMO_TENANT_ENTERPRISE_CHAIN,
"Panadería Central - Obrador Madrid (Enterprise Parent)",
suppliers_data
)
results.append(result_enterprise_parent)
# Calculate totals
total_suppliers = sum(r["suppliers_created"] for r in results)
total_price_lists = sum(r["price_lists_created"] for r in results)
total_skipped = sum(r["suppliers_skipped"] for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Suppliers Seeding Completed")
logger.info("=" * 80)
return {
"service": "suppliers",
"tenants_seeded": len(results),
"total_suppliers_created": total_suppliers,
"total_price_lists_created": total_price_lists,
"total_skipped": total_skipped,
"results": results
}
async def main():
"""Main execution function"""
logger.info("Demo Suppliers Seeding Script Starting")
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URL from environment
database_url = os.getenv("SUPPLIERS_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ SUPPLIERS_DATABASE_URL or DATABASE_URL environment variable must be set")
return 1
# Convert to async URL if needed
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
logger.info("Connecting to suppliers database")
# Create engine and session
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
session_maker = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with session_maker() as session:
result = await seed_suppliers(session)
logger.info("")
logger.info("📊 Seeding Summary:")
logger.info(f" ✅ Tenants seeded: {result['tenants_seeded']}")
logger.info(f" ✅ Suppliers created: {result['total_suppliers_created']}")
logger.info(f" ✅ Price lists created: {result['total_price_lists_created']}")
logger.info(f" ⏭️ Skipped: {result['total_skipped']}")
logger.info("")
# Print per-tenant details
for tenant_result in result['results']:
logger.info(
f" {tenant_result['tenant_name']}: "
f"{tenant_result['suppliers_created']} suppliers, "
f"{tenant_result['price_lists_created']} price lists"
)
logger.info("")
logger.info("🎉 Success! Supplier catalog is ready for cloning.")
logger.info("")
logger.info("Suppliers created:")
logger.info(" • Molinos San José S.L. (harinas)")
logger.info(" • Lácteos del Valle S.A. (lácteos)")
logger.info(" • Lesaffre Ibérica (levaduras)")
logger.info(" • And 9 more suppliers...")
logger.info("")
logger.info("Note: All IDs are pre-defined and hardcoded for cross-service consistency")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Suppliers Seeding Failed")
logger.error("=" * 80)
logger.error("Error: %s", str(e))
logger.error("", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)