New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View File

@@ -54,8 +54,8 @@ structlog.configure(
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
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 = {
@@ -128,7 +128,7 @@ def weighted_choice(choices: list) -> dict:
def generate_plan_number(tenant_id: uuid.UUID, index: int, plan_type: str) -> str:
"""Generate a unique plan number"""
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_SAN_PABLO else "LE"
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_PROFESSIONAL else "LE"
type_code = plan_type[0:3].upper()
return f"PROC-{tenant_prefix}-{type_code}-{BASE_REFERENCE_DATE.year}-{index:03d}"
@@ -487,7 +487,8 @@ async def seed_all(db: AsyncSession):
"requirements_per_plan": {"min": 3, "max": 8},
"planning_horizon_days": {
"individual_bakery": 30,
"central_bakery": 45
"central_bakery": 45,
"enterprise_chain": 45 # Enterprise parent uses same horizon as central bakery
},
"safety_stock_percentage": {"min": 15.0, "max": 25.0},
"temporal_distribution": {
@@ -561,25 +562,25 @@ async def seed_all(db: AsyncSession):
results = []
# Seed San Pablo (Individual Bakery)
result_san_pablo = await generate_procurement_for_tenant(
# Seed Professional Bakery (single location)
result_professional = await generate_procurement_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Individual Bakery)",
DEMO_TENANT_PROFESSIONAL,
"Panadería Artesana Madrid (Professional)",
"individual_bakery",
config
)
results.append(result_san_pablo)
results.append(result_professional)
# Seed La Espiga (Central Bakery)
result_la_espiga = await generate_procurement_for_tenant(
# Seed Enterprise Parent (central production - Obrador) with scaled procurement
result_enterprise_parent = await generate_procurement_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Bakery)",
"central_bakery",
DEMO_TENANT_ENTERPRISE_CHAIN,
"Panadería Central - Obrador Madrid (Enterprise Parent)",
"enterprise_chain",
config
)
results.append(result_la_espiga)
results.append(result_enterprise_parent)
total_plans = sum(r["plans_created"] for r in results)
total_requirements = sum(r["requirements_created"] for r in results)

View File

@@ -41,14 +41,18 @@ from shared.schemas.reasoning_types import (
create_po_reasoning_low_stock,
create_po_reasoning_supplier_contract
)
from shared.utils.demo_dates import BASE_REFERENCE_DATE
# Configure logging
logger = structlog.get_logger()
# Demo tenant IDs (match those from orders service)
# Demo tenant IDs (match those from tenant service)
DEMO_TENANT_IDS = [
uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"), # San Pablo
uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # La Espiga
uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"), # Professional Bakery (standalone)
uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8"), # Enterprise Chain (parent)
uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9"), # Enterprise Child 1 (Madrid)
uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0"), # Enterprise Child 2 (Barcelona)
uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1"), # Enterprise Child 3 (Valencia)
]
# System user ID for auto-approvals
@@ -252,12 +256,12 @@ async def create_purchase_order(
) -> PurchaseOrder:
"""Create a purchase order with items"""
created_at = datetime.now(timezone.utc) + timedelta(days=created_offset_days)
created_at = BASE_REFERENCE_DATE + timedelta(days=created_offset_days)
required_delivery = created_at + timedelta(days=random.randint(3, 7))
# Generate unique PO number
while True:
po_number = f"PO-{datetime.now().year}-{random.randint(100, 999)}"
po_number = f"PO-{BASE_REFERENCE_DATE.year}-{random.randint(100, 999)}"
# Check if PO number already exists in the database
existing_po = await db.execute(
select(PurchaseOrder).where(PurchaseOrder.po_number == po_number).limit(1)
@@ -599,7 +603,7 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
pos_created.append(po10)
# 11. DELIVERY OVERDUE - Expected delivery is 4 hours late (URGENT dashboard alert)
delivery_overdue_time = datetime.now(timezone.utc) - timedelta(hours=4)
delivery_overdue_time = BASE_REFERENCE_DATE - timedelta(hours=4)
po11 = await create_purchase_order(
db, tenant_id, supplier_high_trust,
PurchaseOrderStatus.sent_to_supplier,
@@ -617,7 +621,7 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
pos_created.append(po11)
# 12. DELIVERY ARRIVING SOON - Arriving in 8 hours (TODAY dashboard alert)
arriving_soon_time = datetime.now(timezone.utc) + timedelta(hours=8)
arriving_soon_time = BASE_REFERENCE_DATE + timedelta(hours=8)
po12 = await create_purchase_order(
db, tenant_id, supplier_medium_trust,
PurchaseOrderStatus.sent_to_supplier,
@@ -652,12 +656,162 @@ async def seed_purchase_orders_for_tenant(db: AsyncSession, tenant_id: uuid.UUID
return pos_created
async def seed_internal_transfer_pos_for_child(
db: AsyncSession,
child_tenant_id: uuid.UUID,
parent_tenant_id: uuid.UUID,
child_name: str
) -> List[PurchaseOrder]:
"""
Seed internal transfer purchase orders from child to parent tenant
These are POs where:
- tenant_id = child (the requesting outlet)
- supplier_id = parent (the supplier)
- is_internal = True
- transfer_type = 'finished_goods'
"""
logger.info(
"Seeding internal transfer POs for child tenant",
child_tenant_id=str(child_tenant_id),
parent_tenant_id=str(parent_tenant_id),
child_name=child_name
)
internal_pos = []
# Create 5-7 internal transfer POs per child for realistic history
num_transfers = random.randint(5, 7)
# Common finished goods that children request from parent
finished_goods_items = [
[
{"name": "Baguette Tradicional", "quantity": 50, "unit_price": 1.20, "uom": "unidad"},
{"name": "Pan de Molde Integral", "quantity": 30, "unit_price": 2.50, "uom": "unidad"},
],
[
{"name": "Croissant Mantequilla", "quantity": 40, "unit_price": 1.80, "uom": "unidad"},
{"name": "Napolitana Chocolate", "quantity": 25, "unit_price": 2.00, "uom": "unidad"},
],
[
{"name": "Pan de Masa Madre", "quantity": 20, "unit_price": 3.50, "uom": "unidad"},
{"name": "Pan Rústico", "quantity": 30, "unit_price": 2.80, "uom": "unidad"},
],
[
{"name": "Ensaimada", "quantity": 15, "unit_price": 3.20, "uom": "unidad"},
{"name": "Palmera", "quantity": 20, "unit_price": 2.50, "uom": "unidad"},
],
[
{"name": "Bollo Suizo", "quantity": 30, "unit_price": 1.50, "uom": "unidad"},
{"name": "Donut Glaseado", "quantity": 25, "unit_price": 1.80, "uom": "unidad"},
]
]
for i in range(num_transfers):
# Vary creation dates: some recent, some from past weeks
created_offset = -random.randint(0, 21) # Last 3 weeks
# Select items for this transfer
items = finished_goods_items[i % len(finished_goods_items)]
# Calculate total
total_amount = sum(Decimal(str(item["quantity"] * item["unit_price"])) for item in items)
# Vary status: most completed, some in progress
if i < num_transfers - 2:
status = PurchaseOrderStatus.completed
elif i == num_transfers - 2:
status = PurchaseOrderStatus.approved
else:
status = PurchaseOrderStatus.pending_approval
created_at = BASE_REFERENCE_DATE + timedelta(days=created_offset)
# Generate unique internal transfer PO number
while True:
po_number = f"INT-{child_name[:3].upper()}-{random.randint(1000, 9999)}"
existing_po = await db.execute(
select(PurchaseOrder).where(PurchaseOrder.po_number == po_number).limit(1)
)
if not existing_po.scalar_one_or_none():
break
# Delivery typically 2-3 days for internal transfers
required_delivery = created_at + timedelta(days=random.randint(2, 3))
# Create internal transfer PO
po = PurchaseOrder(
tenant_id=child_tenant_id, # PO belongs to child
supplier_id=parent_tenant_id, # Parent is the "supplier"
po_number=po_number,
status=status,
is_internal=True, # CRITICAL: Mark as internal transfer
source_tenant_id=parent_tenant_id, # Source is parent
destination_tenant_id=child_tenant_id, # Destination is child
transfer_type="finished_goods", # Transfer finished products
subtotal=total_amount,
tax_amount=Decimal("0.00"), # No tax on internal transfers
shipping_cost=Decimal("0.00"), # No shipping cost for internal
total_amount=total_amount,
required_delivery_date=required_delivery,
expected_delivery_date=required_delivery if status != PurchaseOrderStatus.pending_approval else None,
notes=f"Internal transfer request from {child_name} outlet",
created_at=created_at,
updated_at=created_at,
created_by=SYSTEM_USER_ID,
updated_by=SYSTEM_USER_ID
)
if status == PurchaseOrderStatus.completed:
po.approved_at = created_at + timedelta(hours=2)
po.sent_to_supplier_at = created_at + timedelta(hours=3)
po.delivered_at = required_delivery
po.completed_at = required_delivery
db.add(po)
await db.flush() # Get PO ID
# Add items
for item_data in items:
item = PurchaseOrderItem(
purchase_order_id=po.id,
tenant_id=child_tenant_id, # Set tenant_id for the item
inventory_product_id=uuid.uuid4(), # Would link to actual inventory items
product_name=item_data["name"],
ordered_quantity=Decimal(str(item_data["quantity"])),
unit_price=Decimal(str(item_data["unit_price"])),
unit_of_measure=item_data["uom"],
line_total=Decimal(str(item_data["quantity"] * item_data["unit_price"]))
)
db.add(item)
internal_pos.append(po)
await db.commit()
logger.info(
f"Successfully created {len(internal_pos)} internal transfer POs",
child_tenant_id=str(child_tenant_id),
child_name=child_name
)
return internal_pos
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with purchase orders"""
logger.info("Starting demo purchase orders seed process")
all_pos = []
# Enterprise parent and children IDs
ENTERPRISE_PARENT = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8")
ENTERPRISE_CHILDREN = [
(uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9"), "Madrid Centro"),
(uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0"), "Barcelona Gràcia"),
(uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1"), "Valencia Ruzafa"),
]
for tenant_id in DEMO_TENANT_IDS:
# Check if POs already exist
result = await db.execute(
@@ -669,12 +823,29 @@ async def seed_all(db: AsyncSession):
logger.info(f"Purchase orders already exist for tenant {tenant_id}, skipping")
continue
# Seed regular external POs for all tenants
pos = await seed_purchase_orders_for_tenant(db, tenant_id)
all_pos.extend(pos)
# Additionally, seed internal transfer POs for enterprise children
for child_id, child_name in ENTERPRISE_CHILDREN:
if tenant_id == child_id:
internal_pos = await seed_internal_transfer_pos_for_child(
db, child_id, ENTERPRISE_PARENT, child_name
)
all_pos.extend(internal_pos)
logger.info(
f"Added {len(internal_pos)} internal transfer POs for {child_name}",
child_id=str(child_id)
)
return {
"total_pos_created": len(all_pos),
"tenants_seeded": len(DEMO_TENANT_IDS),
"internal_transfers_created": sum(
1 for child_id, _ in ENTERPRISE_CHILDREN
if any(po.tenant_id == child_id and po.is_internal for po in all_pos)
),
"status": "completed"
}