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:
Claude
2025-11-07 18:16:44 +00:00
parent 6ee8c055ee
commit ddc4928d78
3 changed files with 446 additions and 2 deletions

View File

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

View File

@@ -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),
}