New enterprise feature
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
175
services/procurement/app/api/internal_transfer.py
Normal file
175
services/procurement/app/api/internal_transfer.py
Normal 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)}")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
409
services/procurement/app/services/internal_transfer_service.py
Normal file
409
services/procurement/app/services/internal_transfer_service.py
Normal 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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user