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

@@ -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