409 lines
17 KiB
Python
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 |