Files
bakery-ia/services/suppliers/app/api/internal_demo.py

578 lines
24 KiB
Python
Raw Normal View History

"""
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
2025-10-21 19:50:07 +02:00
# 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,
2025-10-21 19:50:07 +02:00
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 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
2025-10-21 19:50:07 +02:00
# Transform inventory_product_id to match virtual tenant's ingredient IDs
if item.inventory_product_id:
base_product_int = int(item.inventory_product_id.hex, 16)
base_ingredient_int = base_tenant_int ^ base_product_int
new_inventory_product_id = uuid.UUID(int=virtual_tenant_int ^ base_ingredient_int)
else:
new_inventory_product_id = 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,
2025-10-21 19:50:07 +02:00
inventory_product_id=new_inventory_product_id, # Transformed 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)
2025-10-21 19:50:07 +02:00
# Transform inventory_product_id to match virtual tenant's ingredient IDs
if item.inventory_product_id:
base_product_int = int(item.inventory_product_id.hex, 16)
base_ingredient_int = base_tenant_int ^ base_product_int
new_inventory_product_id = uuid.UUID(int=virtual_tenant_int ^ base_ingredient_int)
else:
new_inventory_product_id = None
new_item = DeliveryItem(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
delivery_id=new_delivery_id,
purchase_order_item_id=new_po_item_id,
2025-10-21 19:50:07 +02:00
inventory_product_id=new_inventory_product_id, # Transformed 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"
}