New enterprise feature
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user