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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user