Files
bakery-ia/services/procurement/app/services/internal_transfer_service.py
2025-11-30 09:12:40 +01:00

409 lines
17 KiB
Python

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