feat: Implement structured reasoning_data generation for i18n support
Implemented proper reasoning data generation for purchase orders and
production batches to enable multilingual dashboard support.
Backend Strategy:
- Generate structured JSON with type codes and parameters
- Store only reasoning_data (JSONB), not hardcoded text
- Frontend will translate using i18n libraries
Changes:
1. Created shared/schemas/reasoning_types.py
- Defined reasoning types for POs and batches
- Created helper functions for common reasoning patterns
- Supports multiple reasoning types (low_stock, forecast_demand, etc.)
2. Production Service (services/production/app/services/production_service.py)
- Generate reasoning_data when creating batches from forecast
- Include parameters: product_name, predicted_demand, current_stock, etc.
- Structure supports frontend i18n interpolation
3. Procurement Service (services/procurement/app/services/procurement_service.py)
- Implemented actual PO creation (was placeholder before!)
- Groups requirements by supplier
- Generates reasoning_data based on context (low_stock vs forecast)
- Creates PO items automatically
Example reasoning_data:
{
"type": "low_stock_detection",
"parameters": {
"supplier_name": "Harinas del Norte",
"product_names": ["Flour Type 55", "Flour Type 45"],
"days_until_stockout": 3,
"current_stock": 45.5,
"required_stock": 200
},
"consequence": {
"type": "stockout_risk",
"severity": "high",
"impact_days": 3
}
}
Frontend will translate:
- EN: "Low stock detected for Harinas del Norte. Stock runs out in 3 days."
- ES: "Stock bajo detectado para Harinas del Norte. Se agota en 3 días."
- CA: "Estoc baix detectat per Harinas del Norte. S'esgota en 3 dies."
Next steps:
- Remove TEXT fields (reasoning, consequence) from models
- Update dashboard service to use reasoning_data
- Create frontend i18n translation keys
- Update dashboard components to translate dynamically
This commit is contained in:
@@ -872,8 +872,172 @@ class ProcurementService:
|
||||
return f"PLAN-{timestamp}"
|
||||
|
||||
async def _create_purchase_orders_from_plan(self, tenant_id, plan_id, auto_approve):
|
||||
"""Create POs from plan (placeholder)"""
|
||||
return {'success': True, 'created_pos': []}
|
||||
"""
|
||||
Create purchase orders from procurement plan requirements
|
||||
|
||||
Groups requirements by supplier and creates POs with reasoning data
|
||||
"""
|
||||
try:
|
||||
from shared.schemas.reasoning_types import create_po_reasoning_low_stock, create_po_reasoning_forecast_demand
|
||||
from collections import defaultdict
|
||||
|
||||
# Get plan requirements
|
||||
requirements = await self.requirement_repo.get_requirements_by_plan(plan_id, tenant_id)
|
||||
|
||||
if not requirements:
|
||||
logger.warning(f"No requirements found for plan {plan_id}")
|
||||
return {'success': False, 'created_pos': [], 'error': 'No requirements found'}
|
||||
|
||||
# Group requirements by supplier
|
||||
supplier_requirements = defaultdict(list)
|
||||
for req in requirements:
|
||||
supplier_id = req.preferred_supplier_id
|
||||
if supplier_id:
|
||||
supplier_requirements[supplier_id].append(req)
|
||||
|
||||
created_pos = []
|
||||
|
||||
# Create a PO for each supplier
|
||||
for supplier_id, reqs in supplier_requirements.items():
|
||||
try:
|
||||
# Get supplier info
|
||||
supplier = await self._get_supplier_by_id(tenant_id, supplier_id)
|
||||
supplier_name = supplier.get('name', 'Unknown Supplier') if supplier else 'Unknown Supplier'
|
||||
|
||||
# Calculate PO totals
|
||||
subtotal = sum(
|
||||
(req.estimated_unit_cost or 0) * (req.net_requirement or 0)
|
||||
for req in reqs
|
||||
)
|
||||
|
||||
# Determine earliest required delivery date
|
||||
required_delivery_date = min(req.required_by_date for req in reqs)
|
||||
|
||||
# Calculate urgency based on delivery date
|
||||
days_until_delivery = (required_delivery_date - date.today()).days
|
||||
|
||||
# Collect product names for reasoning
|
||||
product_names = [req.product_name for req in reqs[:3]] # First 3 products
|
||||
if len(reqs) > 3:
|
||||
product_names.append(f"and {len(reqs) - 3} more")
|
||||
|
||||
# Determine reasoning type based on requirements
|
||||
# If any requirement has low stock, use low_stock_detection
|
||||
has_low_stock = any(
|
||||
(req.current_stock_level or 0) < (req.total_quantity_needed or 0) * 0.3
|
||||
for req in reqs
|
||||
)
|
||||
|
||||
if has_low_stock:
|
||||
# Calculate aggregate stock data for reasoning
|
||||
total_current = sum(req.current_stock_level or 0 for req in reqs)
|
||||
total_required = sum(req.total_quantity_needed or 0 for req in reqs)
|
||||
|
||||
reasoning_data = create_po_reasoning_low_stock(
|
||||
supplier_name=supplier_name,
|
||||
product_names=product_names,
|
||||
current_stock=float(total_current),
|
||||
required_stock=float(total_required),
|
||||
days_until_stockout=days_until_delivery,
|
||||
threshold_percentage=20,
|
||||
affected_products=[req.product_name for req in reqs]
|
||||
)
|
||||
else:
|
||||
# Use forecast-based reasoning
|
||||
reasoning_data = create_po_reasoning_forecast_demand(
|
||||
supplier_name=supplier_name,
|
||||
product_names=product_names,
|
||||
forecast_period_days=7,
|
||||
total_demand=float(sum(req.order_demand or 0 for req in reqs))
|
||||
)
|
||||
|
||||
# Generate PO number
|
||||
po_number = await self._generate_po_number()
|
||||
|
||||
# Determine if needs approval (based on amount or config)
|
||||
requires_approval = subtotal > 500 or not auto_approve # Example threshold
|
||||
|
||||
# Create PO
|
||||
po_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'supplier_id': supplier_id,
|
||||
'procurement_plan_id': plan_id,
|
||||
'po_number': po_number,
|
||||
'status': 'approved' if auto_approve and not requires_approval else 'pending_approval',
|
||||
'priority': 'high' if days_until_delivery <= 2 else 'normal',
|
||||
'order_date': datetime.now(timezone.utc),
|
||||
'required_delivery_date': datetime.combine(required_delivery_date, datetime.min.time()),
|
||||
'subtotal': Decimal(str(subtotal)),
|
||||
'total_amount': Decimal(str(subtotal)), # Simplified, no tax/shipping
|
||||
'currency': 'EUR',
|
||||
'requires_approval': requires_approval,
|
||||
'reasoning_data': reasoning_data, # NEW: Structured reasoning
|
||||
'created_at': datetime.now(timezone.utc),
|
||||
'updated_at': datetime.now(timezone.utc),
|
||||
'created_by': uuid.uuid4(), # System user
|
||||
'updated_by': uuid.uuid4()
|
||||
}
|
||||
|
||||
po = await self.po_repo.create_purchase_order(po_data)
|
||||
|
||||
# Create PO items
|
||||
po_items = []
|
||||
for req in reqs:
|
||||
item_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'purchase_order_id': po.id,
|
||||
'procurement_requirement_id': req.id,
|
||||
'inventory_product_id': req.product_id,
|
||||
'product_name': req.product_name,
|
||||
'ordered_quantity': req.net_requirement,
|
||||
'unit_of_measure': req.unit_of_measure,
|
||||
'unit_price': req.estimated_unit_cost or Decimal('0'),
|
||||
'line_total': (req.estimated_unit_cost or Decimal('0')) * (req.net_requirement or Decimal('0')),
|
||||
'remaining_quantity': req.net_requirement,
|
||||
'created_at': datetime.now(timezone.utc),
|
||||
'updated_at': datetime.now(timezone.utc)
|
||||
}
|
||||
item = await self.po_item_repo.create_po_item(item_data)
|
||||
po_items.append(item)
|
||||
|
||||
created_pos.append({
|
||||
'id': str(po.id),
|
||||
'po_number': po.po_number,
|
||||
'supplier_id': str(supplier_id),
|
||||
'supplier_name': supplier_name,
|
||||
'total_amount': float(po.total_amount),
|
||||
'status': po.status.value if hasattr(po.status, 'value') else po.status,
|
||||
'items_count': len(po_items)
|
||||
})
|
||||
|
||||
logger.info(f"Created PO {po.po_number} for supplier {supplier_name} with {len(po_items)} items")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating PO for supplier {supplier_id}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'created_pos': created_pos,
|
||||
'count': len(created_pos)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _create_purchase_orders_from_plan: {e}", exc_info=True)
|
||||
return {'success': False, 'created_pos': [], 'error': str(e)}
|
||||
|
||||
async def _generate_po_number(self):
|
||||
"""Generate unique PO number"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
return f"PO-{timestamp}-{uuid.uuid4().hex[:6].upper()}"
|
||||
|
||||
async def _get_supplier_by_id(self, tenant_id, supplier_id):
|
||||
"""Get supplier details by ID"""
|
||||
try:
|
||||
return await self.suppliers_client.get_supplier(str(tenant_id), str(supplier_id))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get supplier {supplier_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _publish_plan_generated_event(self, tenant_id, plan_id):
|
||||
"""Publish plan generated event"""
|
||||
|
||||
@@ -1836,6 +1836,18 @@ class ProductionService:
|
||||
# Note: In a real scenario, we'd fetch recipe_id from product/inventory
|
||||
# For now, we assume recipe_id = product_id or fetch from a mapping
|
||||
|
||||
# Generate reasoning data for JTBD dashboard
|
||||
from shared.schemas.reasoning_types import create_batch_reasoning_forecast_demand
|
||||
|
||||
reasoning_data = create_batch_reasoning_forecast_demand(
|
||||
product_name=f"Product {product_id}", # TODO: Get actual product name from inventory
|
||||
predicted_demand=predicted_demand,
|
||||
current_stock=current_stock,
|
||||
production_needed=production_needed,
|
||||
target_date=target_date.isoformat(),
|
||||
confidence_score=forecast.get('confidence_score', 0.85)
|
||||
)
|
||||
|
||||
# Create production batch
|
||||
batch_data = {
|
||||
'tenant_id': tenant_id,
|
||||
@@ -1847,6 +1859,7 @@ class ProductionService:
|
||||
'planned_start_time': datetime.combine(target_date, datetime.min.time()),
|
||||
'planned_end_time': datetime.combine(target_date, datetime.max.time()),
|
||||
'planned_quantity': production_needed,
|
||||
'reasoning_data': reasoning_data, # NEW: Structured reasoning for i18n
|
||||
'created_at': datetime.now(timezone.utc),
|
||||
'updated_at': datetime.now(timezone.utc),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user