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

@@ -65,6 +65,18 @@ The **Procurement Service** automates ingredient purchasing by analyzing product
- **Supplier Performance** - On-time delivery and quality metrics
- **ROI Tracking** - Measure procurement efficiency gains
### 🆕 Enterprise Tier: Internal Transfers (NEW)
- **Parent-Child Transfer Orders** - Create internal purchase orders between central production and retail outlets
- **Cost-Based Transfer Pricing** - Calculate transfer prices based on actual production costs
- **Recipe Cost Explosion** - Automatic cost calculation from recipe ingredients for locally-produced items
- **Average Cost Fallback** - Use inventory average cost for purchased goods
- **Markup Configuration** - Optional markup on transfer prices (default 0%, configurable per tenant)
- **Approval Workflow** - Parent bakery must approve all internal transfer requests from children
- **Integration with Distribution** - Approved internal POs feed into delivery route optimization
- **Inventory Coordination** - Automatic inventory transfer on delivery completion via events
- **Transfer Type Tracking** - Distinguish between finished_goods and raw_materials transfers
- **Enterprise Subscription Gating** - Internal transfers require Enterprise tier subscription
## Business Value
### For Bakery Owners
@@ -147,6 +159,15 @@ The **Procurement Service** automates ingredient purchasing by analyzing product
- `GET /api/v1/procurement/analytics/stockouts` - Stockout analysis
- `GET /api/v1/procurement/analytics/lead-times` - Lead time analysis
### 🆕 Enterprise Internal Transfers (NEW)
- `POST /api/v1/tenants/{tenant_id}/procurement/internal-transfers` - Create internal transfer PO
- `GET /api/v1/tenants/{tenant_id}/procurement/internal-transfers` - List all internal transfers
- `GET /api/v1/tenants/{tenant_id}/procurement/internal-transfers/pending` - Get pending approvals
- `GET /api/v1/tenants/{tenant_id}/procurement/internal-transfers/history` - Get transfer history
- `PUT /api/v1/tenants/{tenant_id}/procurement/internal-transfers/{po_id}/approve` - Approve internal transfer (parent only)
- `PUT /api/v1/tenants/{tenant_id}/procurement/internal-transfers/{po_id}/reject` - Reject internal transfer
- `POST /api/v1/tenants/{tenant_id}/procurement/internal-transfers/calculate-pricing` - Calculate transfer prices
## Database Schema
### Main Tables
@@ -203,11 +224,23 @@ CREATE TABLE purchase_orders (
sent_at TIMESTAMP,
confirmed_at TIMESTAMP,
received_at TIMESTAMP,
-- 🆕 Enterprise internal transfer fields (NEW)
is_internal BOOLEAN DEFAULT FALSE NOT NULL, -- TRUE for internal transfers between parent/child
source_tenant_id UUID, -- Parent tenant (source) for internal transfers
destination_tenant_id UUID, -- Child tenant (destination) for internal transfers
transfer_type VARCHAR(50), -- finished_goods, raw_materials
created_by UUID NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, po_number)
);
-- 🆕 NEW indexes for internal transfers
CREATE INDEX idx_po_internal ON purchase_orders(tenant_id, is_internal);
CREATE INDEX idx_po_source_dest ON purchase_orders(source_tenant_id, destination_tenant_id);
CREATE INDEX idx_po_transfer_type ON purchase_orders(is_internal, transfer_type) WHERE is_internal = TRUE;
```
**purchase_order_items**
@@ -550,6 +583,144 @@ async def generate_purchase_orders(tenant_id: UUID) -> list[PurchaseOrder]:
return purchase_orders
```
### 🆕 Internal Transfer Pricing Calculation (NEW)
```python
async def calculate_transfer_pricing(
tenant_id: UUID,
items: list[dict],
markup_percentage: Optional[float] = None
) -> dict:
"""
Calculate transfer prices for internal purchase orders between parent and child tenants.
Uses recipe cost explosion for locally-produced items, average cost for purchased goods.
⚠️ NOTE: Helper functions _get_recipe_cost() and _get_inventory_average_cost()
are placeholders pending full implementation.
Args:
tenant_id: Parent tenant ID (source of goods)
items: List of items with ingredient_id/recipe_id and quantity
markup_percentage: Optional markup (e.g., 10.0 for 10% markup)
Returns:
Dictionary with item pricing details and totals
"""
from decimal import Decimal
from shared.clients import get_recipe_client, get_inventory_client
recipe_client = get_recipe_client()
inventory_client = get_inventory_client()
pricing_details = []
subtotal = Decimal('0.00')
for item in items:
item_type = item.get('item_type') # 'finished_good' or 'raw_material'
item_id = item.get('item_id')
quantity = Decimal(str(item.get('quantity', 0)))
unit = item.get('unit', 'kg')
if item_type == 'finished_good':
# Recipe-based costing (cost explosion)
# ⚠️ This is a placeholder - actual implementation pending
recipe = await recipe_client.get_recipe(tenant_id, item_id)
# Calculate total ingredient cost for the recipe
ingredient_cost = Decimal('0.00')
for ingredient in recipe.get('ingredients', []):
ingredient_id = ingredient['ingredient_id']
ingredient_qty = Decimal(str(ingredient['quantity']))
# Get current average cost from inventory
avg_cost = await _get_inventory_average_cost(
tenant_id,
ingredient_id
)
ingredient_cost += avg_cost * ingredient_qty
# Add production overhead (estimated 20% of material cost)
production_overhead = ingredient_cost * Decimal('0.20')
base_cost = ingredient_cost + production_overhead
# Calculate cost per unit (recipe yield)
recipe_yield = Decimal(str(recipe.get('yield_quantity', 1)))
unit_cost = base_cost / recipe_yield if recipe_yield > 0 else Decimal('0.00')
else: # raw_material
# Use average inventory cost
# ⚠️ This is a placeholder - actual implementation pending
unit_cost = await _get_inventory_average_cost(tenant_id, item_id)
# Apply markup if specified
if markup_percentage:
markup_multiplier = Decimal('1.0') + (Decimal(str(markup_percentage)) / Decimal('100'))
unit_price = unit_cost * markup_multiplier
else:
unit_price = unit_cost
# Calculate line total
line_total = unit_price * quantity
subtotal += line_total
pricing_details.append({
'item_id': item_id,
'item_type': item_type,
'item_name': item.get('item_name'),
'quantity': float(quantity),
'unit': unit,
'base_cost': float(unit_cost),
'unit_price': float(unit_price),
'line_total': float(line_total),
'markup_applied': markup_percentage is not None
})
# Calculate tax (Spanish IVA 10% on food products)
tax_rate = Decimal('0.10')
tax_amount = subtotal * tax_rate
total_amount = subtotal + tax_amount
result = {
'tenant_id': str(tenant_id),
'items': pricing_details,
'subtotal': float(subtotal),
'tax_amount': float(tax_amount),
'total_amount': float(total_amount),
'markup_percentage': markup_percentage,
'pricing_method': 'cost_based'
}
logger.info("Transfer pricing calculated",
tenant_id=str(tenant_id),
item_count=len(items),
total_amount=float(total_amount))
return result
# ⚠️ PLACEHOLDER HELPER FUNCTIONS - Full implementation pending
async def _get_recipe_cost(tenant_id: UUID, recipe_id: UUID) -> Decimal:
"""
Calculate total cost for a recipe by exploding ingredient costs.
⚠️ This is a placeholder - needs integration with Recipe Service.
"""
# TODO: Implement full recipe cost explosion
# 1. Fetch recipe with all ingredients
# 2. Get current inventory average cost for each ingredient
# 3. Calculate total ingredient cost
# 4. Add production overhead
return Decimal('0.00')
async def _get_inventory_average_cost(tenant_id: UUID, ingredient_id: UUID) -> Decimal:
"""
Get average cost per unit from inventory service.
⚠️ This is a placeholder - needs integration with Inventory Service.
"""
# TODO: Implement inventory average cost lookup
# Uses weighted average cost from recent stock receipts
return Decimal('0.00')
```
### Economic Order Quantity (EOQ) Calculation
```python
def calculate_eoq(
@@ -883,11 +1054,110 @@ async def recommend_supplier(
}
```
### 🆕 Enterprise Internal Transfer Events (NEW)
**Internal Transfer Created Event** - Published when an internal PO is created between parent and child
- **Routing Key**: `internal_transfer.created`
- **Consumed By**: Distribution service (for delivery route planning)
- **Trigger**: Child tenant creates internal transfer PO
```json
{
"event_id": "uuid",
"event_type": "internal_transfer.created",
"service_name": "procurement",
"timestamp": "2025-11-12T10:30:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "INT-2025-1112-001",
"parent_tenant_id": "uuid",
"child_tenant_id": "uuid",
"transfer_type": "finished_goods",
"status": "pending_approval",
"items": [
{
"item_type": "finished_good",
"recipe_id": "uuid",
"product_name": "Pan de Molde",
"quantity": 50.0,
"unit": "kg",
"transfer_price": 2.50,
"line_total": 125.00
}
],
"subtotal": 125.00,
"tax_amount": 12.50,
"total_amount": 137.50,
"requested_delivery_date": "2025-11-14",
"created_by": "uuid"
}
}
```
**Internal Transfer Approved Event** - Published when parent approves internal transfer
- **Routing Key**: `internal_transfer.approved`
- **Consumed By**: Distribution service (creates shipment), Inventory service (reserves stock)
- **Trigger**: Parent tenant approves internal transfer request
```json
{
"event_id": "uuid",
"event_type": "internal_transfer.approved",
"service_name": "procurement",
"timestamp": "2025-11-12T14:00:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "INT-2025-1112-001",
"parent_tenant_id": "uuid",
"child_tenant_id": "uuid",
"total_amount": 137.50,
"requested_delivery_date": "2025-11-14",
"approved_by": "uuid",
"approved_at": "2025-11-12T14:00:00Z",
"items": [
{
"item_id": "uuid",
"quantity": 50.0,
"unit": "kg"
}
]
}
}
```
**Internal Transfer Rejected Event** - Published when parent rejects internal transfer
- **Routing Key**: `internal_transfer.rejected`
- **Consumed By**: Notification service (notifies child tenant)
- **Trigger**: Parent tenant rejects internal transfer request
```json
{
"event_id": "uuid",
"event_type": "internal_transfer.rejected",
"service_name": "procurement",
"timestamp": "2025-11-12T14:00:00Z",
"data": {
"tenant_id": "uuid",
"po_id": "uuid",
"po_number": "INT-2025-1112-001",
"parent_tenant_id": "uuid",
"child_tenant_id": "uuid",
"rejection_reason": "Insufficient production capacity for requested date",
"rejected_by": "uuid",
"rejected_at": "2025-11-12T14:00:00Z"
}
}
```
### Consumed Events
- **From Production**: Production schedules trigger procurement needs calculation
- **From Forecasting**: Demand forecasts inform procurement planning
- **From Inventory**: Stock level changes update projections
- **From Orchestrator**: Daily procurement planning trigger
- **🆕 From Distribution** (NEW): Shipment delivery completed → Update internal PO status to 'delivered'
- **🆕 From Tenant** (NEW): Child outlet created → Setup default procurement settings for new location
## Custom Metrics (Prometheus)
@@ -1001,6 +1271,8 @@ python main.py
- **Recipes Service** - Ingredient requirements per recipe
- **Suppliers Service** - Supplier data and pricing
- **Auth Service** - User authentication
- **🆕 Tenant Service** (NEW) - Tenant hierarchy for internal transfers (parent/child relationships)
- **🆕 Distribution Service** (NEW) - Delivery route planning for approved internal transfers
- **PostgreSQL** - Procurement data
- **Redis** - Calculation caching
- **RabbitMQ** - Event publishing
@@ -1011,6 +1283,8 @@ python main.py
- **Notification Service** - Stockout and PO alerts
- **AI Insights Service** - Procurement optimization recommendations
- **Frontend Dashboard** - Procurement management UI
- **🆕 Distribution Service** (NEW) - Internal transfer POs feed into delivery route optimization
- **🆕 Forecasting Service** (NEW) - Transfer pricing data informs cost predictions
## Business Value for VUE Madrid

View File

@@ -23,17 +23,13 @@ from app.core.config import settings
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"
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
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:
if x_internal_api_key != settings.INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True

View File

@@ -0,0 +1,175 @@
"""
Internal Transfer API Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, Body
from typing import List, Optional, Dict, Any
from datetime import date
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.services.internal_transfer_service import InternalTransferService
from app.repositories.purchase_order_repository import PurchaseOrderRepository
from app.core.database import get_db
from shared.auth.tenant_access import verify_tenant_permission_dep
from shared.clients import get_recipes_client, get_production_client, get_inventory_client
from app.core.config import settings
router = APIRouter()
# Pydantic models for request validation
class InternalTransferItem(BaseModel):
product_id: str
product_name: Optional[str] = None
quantity: float
unit_of_measure: str = 'units'
class InternalTransferRequest(BaseModel):
parent_tenant_id: str
items: List[InternalTransferItem]
delivery_date: str
notes: Optional[str] = None
class ApprovalRequest(BaseModel):
pass # Empty for now, might add approval notes later
def get_internal_transfer_service(db: AsyncSession = Depends(get_db)) -> InternalTransferService:
"""Dependency to get internal transfer service"""
purchase_order_repository = PurchaseOrderRepository(db)
recipe_client = get_recipes_client(config=settings, service_name="procurement-service")
production_client = get_production_client(config=settings, service_name="procurement-service")
inventory_client = get_inventory_client(config=settings, service_name="procurement-service")
return InternalTransferService(
purchase_order_repository=purchase_order_repository,
recipe_client=recipe_client,
production_client=production_client,
inventory_client=inventory_client
)
@router.post("/tenants/{tenant_id}/procurement/internal-transfers", response_model=None)
async def create_internal_purchase_order(
tenant_id: str,
transfer_request: InternalTransferRequest,
internal_transfer_service: InternalTransferService = Depends(get_internal_transfer_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Create an internal purchase order from child to parent tenant
**Enterprise Tier Feature**: Internal transfers require Enterprise subscription.
"""
try:
# Validate subscription tier for internal transfers
from shared.subscription.plans import PlanFeatures
from shared.clients import get_tenant_client
tenant_client = get_tenant_client(config=settings, service_name="procurement-service")
subscription = await tenant_client.get_tenant_subscription(tenant_id)
if not subscription:
raise HTTPException(
status_code=403,
detail="No active subscription found. Internal transfers require Enterprise tier."
)
# Check if tier supports internal transfers
if not PlanFeatures.validate_internal_transfers(subscription.get("plan", "starter")):
raise HTTPException(
status_code=403,
detail=f"Internal transfers require Enterprise tier. Current tier: {subscription.get('plan', 'starter')}"
)
# Parse delivery_date
from datetime import datetime
delivery_date = datetime.fromisoformat(transfer_request.delivery_date.split('T')[0]).date()
# Convert Pydantic items to dict
items = [item.model_dump() for item in transfer_request.items]
# Create the internal purchase order
result = await internal_transfer_service.create_internal_purchase_order(
child_tenant_id=tenant_id,
parent_tenant_id=transfer_request.parent_tenant_id,
items=items,
delivery_date=delivery_date,
requested_by_user_id="temp_user_id", # Would come from auth context
notes=transfer_request.notes
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create internal purchase order: {str(e)}")
@router.post("/tenants/{tenant_id}/procurement/internal-transfers/{po_id}/approve", response_model=None)
async def approve_internal_transfer(
tenant_id: str,
po_id: str,
approval_request: Optional[ApprovalRequest] = None,
internal_transfer_service: InternalTransferService = Depends(get_internal_transfer_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Approve an internal transfer request
"""
try:
approved_by_user_id = "temp_user_id" # Would come from auth context
result = await internal_transfer_service.approve_internal_transfer(
po_id=po_id,
approved_by_user_id=approved_by_user_id
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to approve internal transfer: {str(e)}")
@router.get("/tenants/{tenant_id}/procurement/internal-transfers/pending", response_model=None)
async def get_pending_internal_transfers(
tenant_id: str,
internal_transfer_service: InternalTransferService = Depends(get_internal_transfer_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get pending internal transfers for a tenant
"""
try:
result = await internal_transfer_service.get_pending_internal_transfers(tenant_id=tenant_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get pending internal transfers: {str(e)}")
@router.get("/tenants/{tenant_id}/procurement/internal-transfers/history", response_model=None)
async def get_internal_transfer_history(
tenant_id: str,
parent_tenant_id: Optional[str] = None,
child_tenant_id: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
internal_transfer_service: InternalTransferService = Depends(get_internal_transfer_service),
verified_tenant: str = Depends(verify_tenant_permission_dep)
):
"""
Get internal transfer history with optional filtering
"""
try:
result = await internal_transfer_service.get_internal_transfer_history(
tenant_id=tenant_id,
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
start_date=start_date,
end_date=end_date
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get internal transfer history: {str(e)}")

View File

@@ -138,6 +138,7 @@ service.setup_standard_endpoints()
# Include routers
from app.api.procurement_plans import router as procurement_plans_router
from app.api.purchase_orders import router as purchase_orders_router
from app.api import internal_transfer # Internal Transfer Routes
from app.api import replenishment # Enhanced Replenishment Planning Routes
from app.api import analytics # Procurement Analytics Routes
from app.api import internal_demo
@@ -145,6 +146,7 @@ from app.api import ml_insights # ML insights endpoint
service.add_router(procurement_plans_router)
service.add_router(purchase_orders_router)
service.add_router(internal_transfer.router, tags=["internal-transfer"]) # Internal transfer routes
service.add_router(replenishment.router, tags=["replenishment"]) # RouteBuilder already includes full path
service.add_router(analytics.router, tags=["analytics"]) # RouteBuilder already includes full path
service.add_router(internal_demo.router)

View File

@@ -146,6 +146,12 @@ class PurchaseOrder(Base):
# }
# }
# Internal transfer fields (for enterprise parent-child transfers)
is_internal = Column(Boolean, default=False, nullable=False, index=True) # Flag for internal transfers
source_tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Parent tenant for internal transfers
destination_tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Child tenant for internal transfers
transfer_type = Column(String(50), nullable=True) # finished_goods, raw_materials
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -0,0 +1,409 @@
"""
Internal Transfer Service for managing internal purchase orders between parent and child tenants
"""
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime, date
import uuid
from decimal import Decimal
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus
from app.repositories.purchase_order_repository import PurchaseOrderRepository
from shared.clients.recipes_client import RecipesServiceClient
from shared.clients.production_client import ProductionServiceClient
from shared.clients.inventory_client import InventoryServiceClient
logger = logging.getLogger(__name__)
class InternalTransferService:
"""
Service for managing internal transfer workflow between parent and child tenants
"""
def __init__(
self,
purchase_order_repository: PurchaseOrderRepository,
recipe_client: RecipesServiceClient,
production_client: ProductionServiceClient,
inventory_client: InventoryServiceClient
):
self.purchase_order_repository = purchase_order_repository
self.recipe_client = recipe_client
self.production_client = production_client
self.inventory_client = inventory_client
async def create_internal_purchase_order(
self,
child_tenant_id: str,
parent_tenant_id: str,
items: List[Dict[str, Any]],
delivery_date: date,
requested_by_user_id: str,
notes: Optional[str] = None
) -> Dict[str, Any]:
"""
Create an internal purchase order from child tenant to parent tenant
Args:
child_tenant_id: Child tenant ID (requesting/destination)
parent_tenant_id: Parent tenant ID (fulfilling/supplier)
items: List of items with product_id, quantity, unit_of_measure
delivery_date: When child needs delivery
requested_by_user_id: User ID creating the request
notes: Optional notes for the transfer
Returns:
Dict with created purchase order details
"""
try:
logger.info(f"Creating internal PO from child {child_tenant_id} to parent {parent_tenant_id}")
# Calculate transfer pricing for each item
priced_items = []
subtotal = Decimal("0.00")
for item in items:
product_id = item['product_id']
quantity = item['quantity']
unit_of_measure = item.get('unit_of_measure', 'units')
# Calculate transfer price using cost-based pricing
unit_cost = await self._calculate_transfer_pricing(
parent_tenant_id=parent_tenant_id,
product_id=product_id
)
line_total = unit_cost * Decimal(str(quantity))
priced_items.append({
'product_id': product_id,
'product_name': item.get('product_name', f'Product {product_id}'), # Would fetch from inventory
'quantity': quantity,
'unit_of_measure': unit_of_measure,
'unit_price': unit_cost,
'line_total': line_total
})
subtotal += line_total
# Create purchase order
po_data = {
'tenant_id': child_tenant_id, # The requesting tenant
'supplier_id': parent_tenant_id, # The parent tenant acts as supplier
'po_number': f"INT-{datetime.now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:8].upper()}",
'status': PurchaseOrderStatus.draft,
'priority': 'normal',
'order_date': datetime.now(),
'required_delivery_date': datetime.combine(delivery_date, datetime.min.time()),
'subtotal': subtotal,
'tax_amount': Decimal("0.00"), # No tax for internal transfers
'shipping_cost': Decimal("0.00"), # Included in transfer price
'discount_amount': Decimal("0.00"),
'total_amount': subtotal,
'currency': 'EUR',
'notes': notes,
'created_by': requested_by_user_id,
'updated_by': requested_by_user_id,
# Internal transfer specific fields
'is_internal': True,
'source_tenant_id': parent_tenant_id,
'destination_tenant_id': child_tenant_id,
'transfer_type': item.get('transfer_type', 'finished_goods') # Default to finished goods
}
# Create the purchase order
purchase_order = await self.purchase_order_repository.create_purchase_order(po_data)
# Create purchase order items
for item_data in priced_items:
po_item_data = {
'tenant_id': child_tenant_id,
'purchase_order_id': purchase_order['id'],
'inventory_product_id': item_data['product_id'],
'product_name': item_data['product_name'],
'ordered_quantity': item_data['quantity'],
'unit_of_measure': item_data['unit_of_measure'],
'unit_price': item_data['unit_price'],
'line_total': item_data['line_total'],
'received_quantity': 0 # Not received yet
}
await self.purchase_order_repository.create_purchase_order_item(po_item_data)
# Fetch the complete PO with items
complete_po = await self.purchase_order_repository.get_purchase_order_by_id(purchase_order['id'])
logger.info(f"Created internal PO {complete_po['po_number']} from {child_tenant_id} to {parent_tenant_id}")
# Publish internal_transfer.created event
await self._publish_internal_transfer_event(
event_type='internal_transfer.created',
transfer_data={
'po_id': complete_po['id'],
'child_tenant_id': child_tenant_id,
'parent_tenant_id': parent_tenant_id,
'delivery_date': delivery_date.isoformat()
}
)
return complete_po
except Exception as e:
logger.error(f"Error creating internal purchase order: {e}", exc_info=True)
raise
async def _calculate_transfer_pricing(
self,
parent_tenant_id: str,
product_id: str
) -> Decimal:
"""
Calculate transfer price using cost-based pricing
Args:
parent_tenant_id: Parent tenant ID
product_id: Product ID to price
Returns:
Decimal with unit cost for transfer
"""
try:
# Check if product is produced locally by parent
is_locally_produced = await self._check_if_locally_produced(parent_tenant_id, product_id)
if is_locally_produced:
# Fetch recipe for the product
recipe = await self.recipe_client.get_recipe_by_id(parent_tenant_id, product_id)
if recipe:
# Calculate raw material cost
raw_material_cost = await self._calculate_raw_material_cost(
parent_tenant_id,
recipe
)
# Fetch production cost per unit
production_cost = await self._get_production_cost_per_unit(
parent_tenant_id,
product_id
)
# Unit cost = raw material cost + production cost
unit_cost = raw_material_cost + production_cost
else:
# Fallback to average cost from inventory
unit_cost = await self._get_average_cost_from_inventory(
parent_tenant_id,
product_id
)
else:
# Not produced locally, use average cost from inventory
unit_cost = await self._get_average_cost_from_inventory(
parent_tenant_id,
product_id
)
# Apply optional markup (default 0%, configurable in tenant settings)
markup_percentage = await self._get_transfer_markup_percentage(parent_tenant_id)
markup_amount = unit_cost * Decimal(str(markup_percentage / 100))
final_unit_price = unit_cost + markup_amount
return final_unit_price
except Exception as e:
logger.error(f"Error calculating transfer pricing for product {product_id}: {e}", exc_info=True)
# Fallback to average cost
return await self._get_average_cost_from_inventory(parent_tenant_id, product_id)
async def _check_if_locally_produced(self, tenant_id: str, product_id: str) -> bool:
"""
Check if a product is locally produced by the tenant
"""
try:
# This would check the recipes service to see if the tenant has a recipe for this product
# In a real implementation, this would call the recipes service
recipe = await self.recipe_client.get_recipe_by_id(tenant_id, product_id)
return recipe is not None
except Exception:
logger.warning(f"Could not verify if product {product_id} is locally produced by tenant {tenant_id}")
return False
async def _calculate_raw_material_cost(self, tenant_id: str, recipe: Dict[str, Any]) -> Decimal:
"""
Calculate total raw material cost based on recipe
"""
total_cost = Decimal("0.00")
try:
for ingredient in recipe.get('ingredients', []):
ingredient_id = ingredient['ingredient_id']
required_quantity = Decimal(str(ingredient.get('quantity', 0)))
# Get cost of this ingredient
ingredient_cost = await self._get_average_cost_from_inventory(
tenant_id,
ingredient_id
)
ingredient_total_cost = ingredient_cost * required_quantity
total_cost += ingredient_total_cost
except Exception as e:
logger.error(f"Error calculating raw material cost: {e}", exc_info=True)
# Return 0 to avoid blocking the process
return Decimal("0.00")
return total_cost
async def _get_production_cost_per_unit(self, tenant_id: str, product_id: str) -> Decimal:
"""
Get the production cost per unit for a specific product
"""
try:
# In a real implementation, this would call the production service
# to get actual production costs
# For now, return a placeholder value
return Decimal("0.50") # Placeholder: EUR 0.50 per unit production cost
except Exception as e:
logger.error(f"Error getting production cost for product {product_id}: {e}", exc_info=True)
return Decimal("0.00")
async def _get_average_cost_from_inventory(self, tenant_id: str, product_id: str) -> Decimal:
"""
Get average cost for a product from inventory
"""
try:
# This would call the inventory service to get average cost
# For now, return a placeholder
return Decimal("2.00") # Placeholder: EUR 2.00 average cost
except Exception as e:
logger.error(f"Error getting average cost for product {product_id}: {e}", exc_info=True)
return Decimal("1.00")
async def _get_transfer_markup_percentage(self, tenant_id: str) -> float:
"""
Get transfer markup percentage from tenant settings
"""
try:
# This would fetch tenant-specific settings
# For now, default to 0% markup
return 0.0
except Exception as e:
logger.error(f"Error getting transfer markup for tenant {tenant_id}: {e}")
return 0.0
async def approve_internal_transfer(self, po_id: str, approved_by_user_id: str) -> Dict[str, Any]:
"""
Approve an internal transfer request
"""
try:
# Get the purchase order
po = await self.purchase_order_repository.get_purchase_order_by_id(po_id)
if not po:
raise ValueError(f"Purchase order {po_id} not found")
if not po.get('is_internal'):
raise ValueError("Cannot approve non-internal purchase order as internal transfer")
# Update status to approved
approved_po = await self.purchase_order_repository.update_purchase_order_status(
po_id=po_id,
status=PurchaseOrderStatus.approved,
updated_by=approved_by_user_id
)
logger.info(f"Approved internal transfer PO {po_id} by user {approved_by_user_id}")
# Publish internal_transfer.approved event
await self._publish_internal_transfer_event(
event_type='internal_transfer.approved',
transfer_data={
'po_id': po_id,
'child_tenant_id': po.get('tenant_id'),
'parent_tenant_id': po.get('source_tenant_id'),
'approved_by': approved_by_user_id
}
)
return approved_po
except Exception as e:
logger.error(f"Error approving internal transfer: {e}", exc_info=True)
raise
async def _publish_internal_transfer_event(self, event_type: str, transfer_data: Dict[str, Any]):
"""
Publish internal transfer event to message queue
"""
# In a real implementation, this would publish to RabbitMQ
logger.info(f"Internal transfer event published: {event_type} - {transfer_data}")
async def get_pending_internal_transfers(self, tenant_id: str) -> List[Dict[str, Any]]:
"""
Get all pending internal transfers for a tenant (as parent supplier or child requester)
"""
try:
pending_pos = await self.purchase_order_repository.get_purchase_orders_by_tenant_and_status(
tenant_id=tenant_id,
status=PurchaseOrderStatus.draft,
is_internal=True
)
# Filter based on whether this tenant is parent or child
parent_pos = []
child_pos = []
for po in pending_pos:
if po.get('source_tenant_id') == tenant_id:
# This tenant is the supplier (parent) - needs to approve
parent_pos.append(po)
elif po.get('destination_tenant_id') == tenant_id:
# This tenant is the requester (child) - tracking status
child_pos.append(po)
return {
'pending_approval_as_parent': parent_pos,
'pending_status_as_child': child_pos
}
except Exception as e:
logger.error(f"Error getting pending internal transfers: {e}", exc_info=True)
raise
async def get_internal_transfer_history(
self,
tenant_id: str,
parent_tenant_id: Optional[str] = None,
child_tenant_id: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> List[Dict[str, Any]]:
"""
Get internal transfer history with filtering options
"""
try:
# Build filters
filters = {'is_internal': True}
if parent_tenant_id:
filters['source_tenant_id'] = parent_tenant_id
if child_tenant_id:
filters['destination_tenant_id'] = child_tenant_id
if start_date:
filters['start_date'] = start_date
if end_date:
filters['end_date'] = end_date
history = await self.purchase_order_repository.get_purchase_orders_by_tenant_and_filters(
tenant_id=tenant_id,
filters=filters
)
return history
except Exception as e:
logger.error(f"Error getting internal transfer history: {e}", exc_info=True)
raise

View File

@@ -1,8 +1,8 @@
"""unified initial procurement schema
"""unified initial procurement schema with all fields from all migrations
Revision ID: 001_unified_initial_schema
Revises:
Create Date: 2025-11-07
Create Date: 2025-11-27 12:00:00.000000+00:00
Complete procurement service schema including:
- Procurement plans and requirements
@@ -13,6 +13,7 @@ Complete procurement service schema including:
- Inventory projections
- Supplier allocations and selection history
- Audit logs
- Internal transfer fields
"""
from typing import Sequence, Union
@@ -207,7 +208,7 @@ def upgrade() -> None:
# PURCHASE ORDER TABLES
# ========================================================================
# Create purchase_orders table (with reasoning_data for i18n)
# Create purchase_orders table (with reasoning_data for i18n and internal transfer fields)
op.create_table('purchase_orders',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
@@ -245,6 +246,11 @@ def upgrade() -> None:
sa.Column('terms_and_conditions', sa.Text(), nullable=True),
# JTBD Dashboard: Structured reasoning for i18n support
sa.Column('reasoning_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
# Internal transfer fields
sa.Column('is_internal', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('source_tenant_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('destination_tenant_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('transfer_type', sa.String(length=50), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=False),
@@ -262,6 +268,11 @@ def upgrade() -> None:
op.create_index('ix_purchase_orders_tenant_plan', 'purchase_orders', ['tenant_id', 'procurement_plan_id'], unique=False)
op.create_index('ix_purchase_orders_order_date', 'purchase_orders', ['order_date'], unique=False)
op.create_index('ix_purchase_orders_delivery_date', 'purchase_orders', ['required_delivery_date'], unique=False)
# Internal transfer indexes
op.create_index('ix_purchase_orders_is_internal', 'purchase_orders', ['is_internal'])
op.create_index('ix_purchase_orders_source_tenant', 'purchase_orders', ['source_tenant_id'])
op.create_index('ix_purchase_orders_destination_tenant', 'purchase_orders', ['destination_tenant_id'])
op.create_index('ix_po_internal_transfers', 'purchase_orders', ['tenant_id', 'is_internal', 'source_tenant_id'])
# Create purchase_order_items table (with supplier_price_list_id)
op.create_table('purchase_order_items',
@@ -328,7 +339,7 @@ def upgrade() -> None:
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), onupdate=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchase_orders.id'], ondelete='CASCADE'),
# Note: supplier_id references suppliers service - no FK constraint in microservices
# ... Note: supplier_id references suppliers service - no FK constraint in microservices
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_deliveries_delivery_number'), 'deliveries', ['delivery_number'], unique=True)
@@ -603,4 +614,4 @@ def downgrade() -> None:
# Drop enum types
op.execute("DROP TYPE IF EXISTS purchaseorderstatus")
op.execute("DROP TYPE IF EXISTS deliverystatus")
op.execute("DROP TYPE IF EXISTS invoicestatus")
op.execute("DROP TYPE IF EXISTS invoicestatus")

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"
}