Improve the demo feature of the project

This commit is contained in:
Urtzi Alfaro
2025-10-12 18:47:33 +02:00
parent dbc7f2fa0d
commit 7556a00db7
168 changed files with 10102 additions and 18869 deletions

View File

@@ -27,8 +27,7 @@ COPY --from=shared /shared /app/shared
# Copy application code
COPY services/suppliers/ .
# Copy scripts directory
COPY scripts/ /app/scripts/
# Add shared libraries to Python path
ENV PYTHONPATH="/app:/app/shared:${PYTHONPATH:-}"

View File

@@ -0,0 +1,539 @@
"""
Internal Demo Cloning API for Suppliers Service
Service-to-service endpoint for cloning supplier and procurement data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import structlog
import uuid
from datetime import datetime, timezone, timedelta, date
from typing import Optional
import os
from app.core.database import get_db
from app.models.suppliers import (
Supplier, SupplierPriceList, PurchaseOrder, PurchaseOrderItem,
Delivery, DeliveryItem, SupplierQualityReview, SupplierInvoice,
SupplierStatus, PurchaseOrderStatus, DeliveryStatus, InvoiceStatus,
QualityRating, DeliveryRating
)
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Internal API key for service-to-service auth
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""
Clone suppliers service data for a virtual demo tenant
Clones:
- Suppliers (vendor master data)
- Supplier price lists (product pricing)
- Purchase orders with items
- Deliveries with items
- Quality reviews
- Supplier invoices
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
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
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"suppliers": 0,
"price_lists": 0,
"purchase_orders": 0,
"purchase_order_items": 0,
"deliveries": 0,
"delivery_items": 0,
"quality_reviews": 0,
"invoices": 0
}
# ID mappings
supplier_id_map = {}
price_list_map = {}
po_id_map = {}
po_item_map = {}
delivery_id_map = {}
# Clone Suppliers
result = await db.execute(
select(Supplier).where(Supplier.tenant_id == base_uuid)
)
base_suppliers = result.scalars().all()
logger.info(
"Found suppliers to clone",
count=len(base_suppliers),
base_tenant=str(base_uuid)
)
for supplier in base_suppliers:
new_supplier_id = uuid.uuid4()
supplier_id_map[supplier.id] = new_supplier_id
new_supplier = Supplier(
id=new_supplier_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
)
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
new_price_list = SupplierPriceList(
id=new_price_id,
tenant_id=virtual_uuid,
supplier_id=new_supplier_id,
inventory_product_id=price_list.inventory_product_id, # Keep product reference
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 Purchase Orders
result = await db.execute(
select(PurchaseOrder).where(PurchaseOrder.tenant_id == base_uuid)
)
base_pos = result.scalars().all()
logger.info(
"Found purchase orders to clone",
count=len(base_pos),
base_tenant=str(base_uuid)
)
# Calculate date offset
if base_pos:
max_date = max(po.order_date for po in base_pos)
today = datetime.now(timezone.utc)
date_offset = today - max_date
else:
date_offset = timedelta(days=0)
for po in base_pos:
new_po_id = uuid.uuid4()
po_id_map[po.id] = new_po_id
new_supplier_id = supplier_id_map.get(po.supplier_id, po.supplier_id)
new_po = PurchaseOrder(
id=new_po_id,
tenant_id=virtual_uuid,
supplier_id=new_supplier_id,
po_number=f"PO-{uuid.uuid4().hex[:8].upper()}", # New PO number
reference_number=po.reference_number,
status=po.status,
priority=po.priority,
order_date=po.order_date + date_offset,
required_delivery_date=po.required_delivery_date + date_offset if po.required_delivery_date else None,
estimated_delivery_date=po.estimated_delivery_date + date_offset if po.estimated_delivery_date else None,
subtotal=po.subtotal,
tax_amount=po.tax_amount,
shipping_cost=po.shipping_cost,
discount_amount=po.discount_amount,
total_amount=po.total_amount,
currency=po.currency,
delivery_address=po.delivery_address,
delivery_instructions=po.delivery_instructions,
delivery_contact=po.delivery_contact,
delivery_phone=po.delivery_phone,
requires_approval=po.requires_approval,
approved_by=po.approved_by,
approved_at=po.approved_at + date_offset if po.approved_at else None,
rejection_reason=po.rejection_reason,
sent_to_supplier_at=po.sent_to_supplier_at + date_offset if po.sent_to_supplier_at else None,
supplier_confirmation_date=po.supplier_confirmation_date + date_offset if po.supplier_confirmation_date else None,
supplier_reference=po.supplier_reference,
notes=po.notes,
internal_notes=po.internal_notes,
terms_and_conditions=po.terms_and_conditions,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=po.created_by,
updated_by=po.updated_by
)
db.add(new_po)
stats["purchase_orders"] += 1
# Flush to get PO IDs
await db.flush()
# Clone Purchase Order Items
for old_po_id, new_po_id in po_id_map.items():
result = await db.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == old_po_id)
)
po_items = result.scalars().all()
for item in po_items:
new_item_id = uuid.uuid4()
po_item_map[item.id] = new_item_id
new_price_list_id = price_list_map.get(item.price_list_item_id, item.price_list_item_id) if item.price_list_item_id else None
new_item = PurchaseOrderItem(
id=new_item_id,
tenant_id=virtual_uuid,
purchase_order_id=new_po_id,
price_list_item_id=new_price_list_id,
inventory_product_id=item.inventory_product_id, # Keep product reference
product_code=item.product_code,
ordered_quantity=item.ordered_quantity,
unit_of_measure=item.unit_of_measure,
unit_price=item.unit_price,
line_total=item.line_total,
received_quantity=item.received_quantity,
remaining_quantity=item.remaining_quantity,
quality_requirements=item.quality_requirements,
item_notes=item.item_notes,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(new_item)
stats["purchase_order_items"] += 1
# Flush to get PO item IDs
await db.flush()
# Clone Deliveries
result = await db.execute(
select(Delivery).where(Delivery.tenant_id == base_uuid)
)
base_deliveries = result.scalars().all()
logger.info(
"Found deliveries to clone",
count=len(base_deliveries),
base_tenant=str(base_uuid)
)
for delivery in base_deliveries:
new_delivery_id = uuid.uuid4()
delivery_id_map[delivery.id] = new_delivery_id
new_po_id = po_id_map.get(delivery.purchase_order_id, delivery.purchase_order_id)
new_supplier_id = supplier_id_map.get(delivery.supplier_id, delivery.supplier_id)
new_delivery = Delivery(
id=new_delivery_id,
tenant_id=virtual_uuid,
purchase_order_id=new_po_id,
supplier_id=new_supplier_id,
delivery_number=f"DEL-{uuid.uuid4().hex[:8].upper()}", # New delivery number
supplier_delivery_note=delivery.supplier_delivery_note,
status=delivery.status,
scheduled_date=delivery.scheduled_date + date_offset if delivery.scheduled_date else None,
estimated_arrival=delivery.estimated_arrival + date_offset if delivery.estimated_arrival else None,
actual_arrival=delivery.actual_arrival + date_offset if delivery.actual_arrival else None,
completed_at=delivery.completed_at + date_offset if delivery.completed_at else None,
delivery_address=delivery.delivery_address,
delivery_contact=delivery.delivery_contact,
delivery_phone=delivery.delivery_phone,
carrier_name=delivery.carrier_name,
tracking_number=delivery.tracking_number,
inspection_passed=delivery.inspection_passed,
inspection_notes=delivery.inspection_notes,
quality_issues=delivery.quality_issues,
received_by=delivery.received_by,
received_at=delivery.received_at + date_offset if delivery.received_at else None,
notes=delivery.notes,
photos=delivery.photos,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=delivery.created_by
)
db.add(new_delivery)
stats["deliveries"] += 1
# Flush to get delivery IDs
await db.flush()
# Clone Delivery Items
for old_delivery_id, new_delivery_id in delivery_id_map.items():
result = await db.execute(
select(DeliveryItem).where(DeliveryItem.delivery_id == old_delivery_id)
)
delivery_items = result.scalars().all()
for item in delivery_items:
new_po_item_id = po_item_map.get(item.purchase_order_item_id, item.purchase_order_item_id)
new_item = DeliveryItem(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
delivery_id=new_delivery_id,
purchase_order_item_id=new_po_item_id,
inventory_product_id=item.inventory_product_id, # Keep product reference
ordered_quantity=item.ordered_quantity,
delivered_quantity=item.delivered_quantity,
accepted_quantity=item.accepted_quantity,
rejected_quantity=item.rejected_quantity,
batch_lot_number=item.batch_lot_number,
expiry_date=item.expiry_date + date_offset if item.expiry_date else None,
quality_grade=item.quality_grade,
quality_issues=item.quality_issues,
rejection_reason=item.rejection_reason,
item_notes=item.item_notes,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(new_item)
stats["delivery_items"] += 1
# 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)
new_po_id = po_id_map.get(review.purchase_order_id, review.purchase_order_id) if review.purchase_order_id else None
new_delivery_id = delivery_id_map.get(review.delivery_id, review.delivery_id) if review.delivery_id else None
new_review = SupplierQualityReview(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
supplier_id=new_supplier_id,
purchase_order_id=new_po_id,
delivery_id=new_delivery_id,
review_date=review.review_date + date_offset,
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=review.follow_up_date + date_offset if review.follow_up_date else None,
is_final=review.is_final,
approved_by=review.approved_by,
created_at=datetime.now(timezone.utc),
reviewed_by=review.reviewed_by
)
db.add(new_review)
stats["quality_reviews"] += 1
# Clone Supplier Invoices
result = await db.execute(
select(SupplierInvoice).where(SupplierInvoice.tenant_id == base_uuid)
)
base_invoices = result.scalars().all()
for invoice in base_invoices:
new_supplier_id = supplier_id_map.get(invoice.supplier_id, invoice.supplier_id)
new_po_id = po_id_map.get(invoice.purchase_order_id, invoice.purchase_order_id) if invoice.purchase_order_id else None
new_invoice = SupplierInvoice(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
supplier_id=new_supplier_id,
purchase_order_id=new_po_id,
invoice_number=f"INV-{uuid.uuid4().hex[:8].upper()}", # New invoice number
supplier_invoice_number=invoice.supplier_invoice_number,
status=invoice.status,
invoice_date=invoice.invoice_date + date_offset,
due_date=invoice.due_date + date_offset,
received_date=invoice.received_date + date_offset,
subtotal=invoice.subtotal,
tax_amount=invoice.tax_amount,
shipping_cost=invoice.shipping_cost,
discount_amount=invoice.discount_amount,
total_amount=invoice.total_amount,
currency=invoice.currency,
paid_amount=invoice.paid_amount,
payment_date=invoice.payment_date + date_offset if invoice.payment_date else None,
payment_reference=invoice.payment_reference,
approved_by=invoice.approved_by,
approved_at=invoice.approved_at + date_offset if invoice.approved_at else None,
rejection_reason=invoice.rejection_reason,
notes=invoice.notes,
invoice_document_url=invoice.invoice_document_url,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
created_by=invoice.created_by
)
db.add(new_invoice)
stats["invoices"] += 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",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "suppliers",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone suppliers data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "suppliers",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "suppliers",
"clone_endpoint": "available",
"version": "2.0.0"
}

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, deliveries, purchase_orders, supplier_operations, analytics
from app.api import suppliers, deliveries, purchase_orders, supplier_operations, analytics, internal_demo
class SuppliersService(StandardFastAPIService):
@@ -107,6 +107,7 @@ service.add_router(deliveries.router)
service.add_router(purchase_orders.router)
service.add_router(supplier_operations.router)
service.add_router(analytics.router)
service.add_router(internal_demo.router)
if __name__ == "__main__":

View File

@@ -0,0 +1,367 @@
{
"proveedores": [
{
"id": "40000000-0000-0000-0000-000000000001",
"name": "Molinos San José S.L.",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B12345678",
"contact_person": "María García López",
"email": "pedidos@molinossj.es",
"phone": "+34 965 123 456",
"mobile": "+34 678 901 234",
"website": "www.molinossanjose.es",
"address_line1": "Calle del Molino, 45",
"city": "Villena",
"state_province": "Alicante",
"postal_code": "03400",
"country": "España",
"payment_terms": "net_30",
"credit_limit": 10000.0,
"standard_lead_time": 2,
"quality_rating": 4.8,
"delivery_rating": 4.9,
"notes": "Proveedor principal de harinas. Excelente calidad y servicio.",
"certifications": [
"ISO 9001:2015",
"IFS Food"
],
"products": [
"HAR-T55-001",
"HAR-T65-002",
"HAR-FUE-003"
]
},
{
"id": "40000000-0000-0000-0000-000000000002",
"name": "Lácteos del Valle S.A.",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "A87654321",
"contact_person": "Juan Martínez Pérez",
"email": "comercial@lacteosv.com",
"phone": "+34 961 234 567",
"mobile": "+34 689 012 345",
"website": "www.lacteosdelvalle.com",
"address_line1": "Polígono Industrial La Granja",
"address_line2": "Parcela 23-25",
"city": "Albal",
"state_province": "Valencia",
"postal_code": "46470",
"country": "España",
"payment_terms": "net_15",
"credit_limit": 5000.0,
"standard_lead_time": 1,
"quality_rating": 4.9,
"delivery_rating": 5.0,
"notes": "Productos lácteos frescos de alta calidad. Entrega diaria.",
"certifications": [
"BRC Food",
"Ecológico"
],
"products": [
"LAC-MAN-001",
"LAC-LEC-002",
"LAC-NAT-003"
]
},
{
"id": "40000000-0000-0000-0000-000000000003",
"name": "Bio Cereales Ibérica",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B98765432",
"contact_person": "Carmen Rodríguez Sanz",
"email": "pedidos@biocereales.es",
"phone": "+34 913 456 789",
"mobile": "+34 645 789 012",
"website": "www.biocereales.es",
"address_line1": "Camino de la Dehesa, 12",
"city": "Toledo",
"state_province": "Toledo",
"postal_code": "45001",
"country": "España",
"payment_terms": "net_30",
"credit_limit": 8000.0,
"standard_lead_time": 3,
"quality_rating": 4.7,
"delivery_rating": 4.6,
"notes": "Especialistas en harinas ecológicas y productos bio.",
"certifications": [
"Ecológico",
"ISO 9001",
"Gluten Free Certified"
],
"products": [
"HAR-INT-004",
"HAR-ESP-006"
]
},
{
"id": "40000000-0000-0000-0000-000000000004",
"name": "Harinas del Campo",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B45678901",
"contact_person": "Pedro Jiménez Castro",
"email": "ventas@harinasdelcampo.es",
"phone": "+34 975 345 678",
"mobile": "+34 634 567 890",
"address_line1": "Carretera Nacional, Km 234",
"city": "Soria",
"state_province": "Soria",
"postal_code": "42001",
"country": "España",
"payment_terms": "net_30",
"credit_limit": 6000.0,
"standard_lead_time": 3,
"quality_rating": 4.6,
"delivery_rating": 4.5,
"notes": "Harinas especiales de centeno y espelta.",
"certifications": [
"ISO 22000"
],
"products": [
"HAR-CEN-005"
]
},
{
"id": "40000000-0000-0000-0000-000000000005",
"name": "Lesaffre Ibérica",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "A23456789",
"contact_person": "Laura Fernández Gil",
"email": "iberia@lesaffre.com",
"phone": "+34 932 567 890",
"mobile": "+34 612 345 678",
"website": "www.lesaffre.es",
"address_line1": "Polígono Industrial Can Salvatella",
"city": "Barberà del Vallès",
"state_province": "Barcelona",
"postal_code": "08210",
"country": "España",
"payment_terms": "net_30",
"credit_limit": 4000.0,
"standard_lead_time": 2,
"quality_rating": 5.0,
"delivery_rating": 4.9,
"notes": "Líder mundial en levaduras. Producto de máxima calidad.",
"certifications": [
"ISO 9001",
"HACCP",
"Halal",
"Kosher"
],
"products": [
"LEV-FRE-001",
"LEV-SEC-002"
]
},
{
"id": "40000000-0000-0000-0000-000000000006",
"name": "Granja Santa Clara",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B34567890",
"contact_person": "Antonio López Martín",
"email": "ventas@granjasantaclara.es",
"phone": "+34 962 456 789",
"mobile": "+34 623 456 789",
"address_line1": "Partida Santa Clara, s/n",
"city": "Alzira",
"state_province": "Valencia",
"postal_code": "46600",
"country": "España",
"payment_terms": "net_15",
"credit_limit": 3000.0,
"standard_lead_time": 1,
"quality_rating": 4.8,
"delivery_rating": 4.9,
"notes": "Leche fresca local de producción propia.",
"certifications": [
"Bienestar Animal",
"Ecológico"
],
"products": [
"LAC-LEC-002"
]
},
{
"id": "40000000-0000-0000-0000-000000000007",
"name": "Granja Los Nogales",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B56789012",
"contact_person": "Rosa María Sánchez",
"email": "huevos@granjalnosnogales.es",
"phone": "+34 945 678 901",
"mobile": "+34 645 678 901",
"address_line1": "Camino de los Nogales, 8",
"city": "Vitoria-Gasteiz",
"state_province": "Álava",
"postal_code": "01006",
"country": "España",
"payment_terms": "net_15",
"credit_limit": 2500.0,
"standard_lead_time": 2,
"quality_rating": 4.7,
"delivery_rating": 4.7,
"notes": "Huevos de gallinas camperas. Categoría A.",
"certifications": [
"Bienestar Animal",
"Gallinas Camperas"
],
"products": [
"LAC-HUE-004"
]
},
{
"id": "40000000-0000-0000-0000-000000000008",
"name": "Valrhona España",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "A67890123",
"contact_person": "Sophie Durand",
"email": "spain@valrhona.com",
"phone": "+34 914 567 890",
"mobile": "+34 656 789 012",
"website": "www.valrhona.es",
"address_line1": "Calle Alcalá, 456",
"city": "Madrid",
"state_province": "Madrid",
"postal_code": "28027",
"country": "España",
"payment_terms": "net_45",
"credit_limit": 15000.0,
"standard_lead_time": 5,
"quality_rating": 5.0,
"delivery_rating": 4.8,
"notes": "Chocolate de cobertura premium. Importación directa de Francia.",
"certifications": [
"UTZ Certified",
"Cocoa Horizons"
],
"products": [
"ESP-CHO-001"
]
},
{
"id": "40000000-0000-0000-0000-000000000009",
"name": "Frutos Secos Valencia",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B78901234",
"contact_person": "Vicente Navarro",
"email": "pedidos@frutosecosvalencia.es",
"phone": "+34 963 567 890",
"mobile": "+34 667 890 123",
"address_line1": "Mercado Central, Puesto 45-47",
"city": "Valencia",
"state_province": "Valencia",
"postal_code": "46001",
"country": "España",
"payment_terms": "net_30",
"credit_limit": 5000.0,
"standard_lead_time": 2,
"quality_rating": 4.6,
"delivery_rating": 4.7,
"notes": "Frutos secos de alta calidad. Almendras españolas.",
"certifications": [
"IFS Food"
],
"products": [
"ESP-ALM-002"
]
},
{
"id": "40000000-0000-0000-0000-000000000010",
"name": "Sal del Mediterráneo",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B89012345",
"contact_person": "Joaquín Martínez",
"email": "ventas@salmediterraneo.es",
"phone": "+34 965 678 901",
"mobile": "+34 678 901 234",
"address_line1": "Salinas de San Pedro",
"city": "San Pedro del Pinatar",
"state_province": "Murcia",
"postal_code": "30740",
"country": "España",
"payment_terms": "net_30",
"credit_limit": 3000.0,
"standard_lead_time": 3,
"quality_rating": 4.5,
"delivery_rating": 4.6,
"notes": "Sal marina de las salinas de Murcia.",
"certifications": [
"Ecológico"
],
"products": [
"BAS-SAL-001"
]
},
{
"id": "40000000-0000-0000-0000-000000000011",
"name": "Azucarera Española S.A.",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "A90123456",
"contact_person": "Cristina Moreno",
"email": "comercial@azucarera.es",
"phone": "+34 915 789 012",
"mobile": "+34 689 012 345",
"website": "www.azucarera.es",
"address_line1": "Paseo de la Castellana, 89",
"city": "Madrid",
"state_province": "Madrid",
"postal_code": "28046",
"country": "España",
"payment_terms": "net_45",
"credit_limit": 8000.0,
"standard_lead_time": 3,
"quality_rating": 4.7,
"delivery_rating": 4.8,
"notes": "Principal proveedor de azúcar. Cobertura nacional.",
"certifications": [
"ISO 9001",
"HACCP"
],
"products": [
"BAS-AZU-002"
]
},
{
"id": "40000000-0000-0000-0000-000000000012",
"name": "Sosa Ingredients",
"supplier_type": "ingredients",
"status": "active",
"tax_id": "B01234567",
"contact_person": "Albert Ferrer",
"email": "info@sosaingredients.com",
"phone": "+34 937 890 123",
"mobile": "+34 690 123 456",
"website": "www.sosaingredients.com",
"address_line1": "Polígono Industrial Can Milans",
"city": "Manlleu",
"state_province": "Barcelona",
"postal_code": "08560",
"country": "España",
"payment_terms": "net_30",
"credit_limit": 6000.0,
"standard_lead_time": 4,
"quality_rating": 4.9,
"delivery_rating": 4.7,
"notes": "Ingredientes premium para pastelería profesional.",
"certifications": [
"HACCP",
"IFS Food"
],
"products": [
"ESP-CRE-005",
"ESP-VAI-004"
]
}
]
}

View File

@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
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_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
# 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" ⏭️ Skipping supplier (exists): {supplier_name}")
skipped_suppliers += 1
continue
# 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))
# 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 San Pablo (Traditional Bakery)
logger.info("")
result_san_pablo = await seed_suppliers_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Traditional)",
suppliers_data
)
results.append(result_san_pablo)
# Seed for La Espiga (Central Workshop)
result_la_espiga = await seed_suppliers_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Workshop)",
suppliers_data
)
results.append(result_la_espiga)
# 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)