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

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