demo seed change
This commit is contained in:
75
.github/workflows/validate-demo-data.yml
vendored
Normal file
75
.github/workflows/validate-demo-data.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Validate Demo Data
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'shared/demo/**'
|
||||
- 'scripts/validate_cross_refs.py'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'shared/demo/**'
|
||||
- 'scripts/validate_cross_refs.py'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
validate-demo-data:
|
||||
name: Validate Demo Data
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyyaml json-schema
|
||||
|
||||
- name: Run cross-reference validation
|
||||
run: |
|
||||
echo "🔍 Running cross-reference validation..."
|
||||
python scripts/validate_cross_refs.py
|
||||
|
||||
- name: Validate JSON schemas
|
||||
run: |
|
||||
echo "📋 Validating JSON schemas..."
|
||||
find shared/demo/schemas -name "*.schema.json" -exec echo "Validating {}" \;
|
||||
# Add schema validation logic here
|
||||
|
||||
- name: Check JSON syntax
|
||||
run: |
|
||||
echo "📝 Checking JSON syntax..."
|
||||
find shared/demo/fixtures -name "*.json" -exec python -m json.tool {} > /dev/null \;
|
||||
echo "✅ All JSON files are valid"
|
||||
|
||||
- name: Validate required fields
|
||||
run: |
|
||||
echo "🔑 Validating required fields..."
|
||||
# Add required field validation logic here
|
||||
|
||||
- name: Check temporal consistency
|
||||
run: |
|
||||
echo "⏰ Checking temporal consistency..."
|
||||
# Add temporal validation logic here
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "🎉 Demo data validation completed successfully!"
|
||||
echo "✅ All checks passed"
|
||||
|
||||
- name: Upload validation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: validation-report
|
||||
path: |
|
||||
validation-report.txt
|
||||
**/validation-*.log
|
||||
if-no-files-found: ignore
|
||||
2335
DEMO_ARCHITECTURE_COMPLETE_SPEC.md
Normal file
2335
DEMO_ARCHITECTURE_COMPLETE_SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,238 +0,0 @@
|
||||
# Root Cause Analysis: Supplier ID Mismatch in Demo Sessions
|
||||
|
||||
## Problem Summary
|
||||
|
||||
In demo sessions, the supplier names are showing as "Unknown" in the Pending Purchases block, even though:
|
||||
1. The Supplier API returns valid suppliers with real names (e.g., "Lácteos del Valle S.A.")
|
||||
2. The alerts contain reasoning data with supplier names
|
||||
3. The PO data has supplier IDs
|
||||
|
||||
## Root Cause
|
||||
|
||||
**The supplier IDs in the alert's reasoning data DO NOT match the cloned supplier IDs.**
|
||||
|
||||
### Why This Happens
|
||||
|
||||
The system uses an XOR-based strategy to generate tenant-specific UUIDs:
|
||||
|
||||
```python
|
||||
# Formula used in all seed scripts:
|
||||
supplier_id = uuid.UUID(int=tenant_int ^ base_supplier_int)
|
||||
```
|
||||
|
||||
However, **the alert seeding script uses hardcoded placeholder IDs that don't follow this pattern:**
|
||||
|
||||
#### In `seed_enriched_alert_demo.py` (Line 45):
|
||||
```python
|
||||
YEAST_SUPPLIER_ID = "supplier-levadura-fresh" # ❌ String ID, not UUID
|
||||
FLOUR_PO_ID = "po-flour-demo-001" # ❌ String ID, not UUID
|
||||
```
|
||||
|
||||
#### In `seed_demo_purchase_orders.py` (Lines 62-67):
|
||||
```python
|
||||
# Hardcoded base supplier IDs (correct pattern)
|
||||
BASE_SUPPLIER_IDS = [
|
||||
uuid.UUID("40000000-0000-0000-0000-000000000001"), # Molinos San José S.L.
|
||||
uuid.UUID("40000000-0000-0000-0000-000000000002"), # Lácteos del Valle S.A.
|
||||
uuid.UUID("40000000-0000-0000-0000-000000000005"), # Lesaffre Ibérica
|
||||
]
|
||||
```
|
||||
|
||||
These base IDs are then XORed with the tenant ID to create unique supplier IDs for each tenant:
|
||||
|
||||
```python
|
||||
# Line 136 of seed_demo_purchase_orders.py
|
||||
tenant_int = int(tenant_id.hex, 16)
|
||||
base_int = int(base_id.hex, 16)
|
||||
supplier_id = uuid.UUID(int=tenant_int ^ base_int) # ✅ Correct cloning pattern
|
||||
```
|
||||
|
||||
## The Data Flow Mismatch
|
||||
|
||||
### 1. Supplier Seeding (Template Tenants)
|
||||
File: `services/suppliers/scripts/demo/seed_demo_suppliers.py`
|
||||
|
||||
```python
|
||||
# Line 155-158: Creates suppliers with XOR-based IDs
|
||||
base_supplier_id = uuid.UUID(supplier_data["id"]) # From proveedores_es.json
|
||||
tenant_int = int(tenant_id.hex, 16)
|
||||
supplier_id = uuid.UUID(int=tenant_int ^ int(base_supplier_id.hex, 16))
|
||||
```
|
||||
|
||||
**Result:** Suppliers are created with tenant-specific UUIDs like:
|
||||
- `uuid.UUID("6e1f9009-e640-48c7-95c5-17d6e7c1da55")` (example from API response)
|
||||
|
||||
### 2. Purchase Order Seeding (Template Tenants)
|
||||
File: `services/procurement/scripts/demo/seed_demo_purchase_orders.py`
|
||||
|
||||
```python
|
||||
# Lines 111-144: Uses same XOR pattern
|
||||
def get_demo_supplier_ids(tenant_id: uuid.UUID):
|
||||
tenant_int = int(tenant_id.hex, 16)
|
||||
|
||||
for i, base_id in enumerate(BASE_SUPPLIER_IDS):
|
||||
base_int = int(base_id.hex, 16)
|
||||
supplier_id = uuid.UUID(int=tenant_int ^ base_int) # ✅ Matches supplier seeding
|
||||
```
|
||||
|
||||
**PO reasoning_data contains:**
|
||||
```python
|
||||
reasoning_data = create_po_reasoning_low_stock(
|
||||
supplier_name=supplier.name, # ✅ CORRECT: Real supplier name like "Lácteos del Valle S.A."
|
||||
product_names=product_names,
|
||||
# ... other parameters
|
||||
)
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- POs are created with correct supplier IDs matching the suppliers
|
||||
- `reasoning_data.parameters.supplier_name` contains the real supplier name (e.g., "Lácteos del Valle S.A.")
|
||||
|
||||
### 3. Alert Seeding (Demo Sessions)
|
||||
File: `services/demo_session/scripts/seed_enriched_alert_demo.py`
|
||||
|
||||
**Problem:** Uses hardcoded string IDs instead of XOR-generated UUIDs:
|
||||
|
||||
```python
|
||||
# Lines 40-46 ❌ WRONG: String IDs instead of proper UUIDs
|
||||
FLOUR_INGREDIENT_ID = "flour-tipo-55"
|
||||
YEAST_INGREDIENT_ID = "yeast-fresh"
|
||||
CROISSANT_PRODUCT_ID = "croissant-mantequilla"
|
||||
CROISSANT_BATCH_ID = "batch-croissants-001"
|
||||
YEAST_SUPPLIER_ID = "supplier-levadura-fresh" # ❌ This doesn't match anything!
|
||||
FLOUR_PO_ID = "po-flour-demo-001"
|
||||
```
|
||||
|
||||
These IDs are then embedded in the alert metadata, but they don't match the actual cloned supplier IDs.
|
||||
|
||||
### 4. Session Cloning Process
|
||||
File: `services/demo_session/app/services/clone_orchestrator.py`
|
||||
|
||||
When a user creates a demo session:
|
||||
1. **Base template tenant** (e.g., `a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6`) is cloned
|
||||
2. **Virtual tenant** is created (e.g., `f8e7d6c5-b4a3-2918-1726-354443526178`)
|
||||
3. **Suppliers are cloned** using XOR pattern:
|
||||
```python
|
||||
# In services/suppliers/app/api/internal_demo.py
|
||||
new_supplier_id = uuid.UUID(int=virtual_tenant_int ^ base_supplier_int)
|
||||
```
|
||||
4. **Purchase orders are cloned** with matching supplier IDs
|
||||
5. **Alerts are generated** but use placeholder string IDs ❌
|
||||
|
||||
## Why the Frontend Shows "Unknown"
|
||||
|
||||
In `useDashboardData.ts` (line 142-144), the code tries to look up supplier names:
|
||||
|
||||
```typescript
|
||||
const supplierName = reasoningInfo?.supplier_name_from_alert || // ✅ This works!
|
||||
supplierMap.get(po.supplier_id) || // ❌ This fails (ID mismatch)
|
||||
po.supplier_name; // ❌ Fallback also fails
|
||||
```
|
||||
|
||||
**However, our fix IS working!** The first line:
|
||||
```typescript
|
||||
reasoningInfo?.supplier_name_from_alert
|
||||
```
|
||||
|
||||
This extracts the supplier name from the alert's reasoning data, which was correctly set during PO creation in `seed_demo_purchase_orders.py` (line 336):
|
||||
|
||||
```python
|
||||
reasoning_data = create_po_reasoning_low_stock(
|
||||
supplier_name=supplier.name, # ✅ Real name like "Lácteos del Valle S.A."
|
||||
# ...
|
||||
)
|
||||
```
|
||||
|
||||
## The Fix We Applied
|
||||
|
||||
In `useDashboardData.ts` (lines 127, 133-134, 142-144):
|
||||
|
||||
```typescript
|
||||
// Extract supplier name from reasoning data
|
||||
const supplierNameFromReasoning = reasoningData?.parameters?.supplier_name;
|
||||
|
||||
poReasoningMap.set(poId, {
|
||||
reasoning_data: reasoningData,
|
||||
ai_reasoning_summary: alert.ai_reasoning_summary || alert.description || alert.i18n?.message_key,
|
||||
supplier_name_from_alert: supplierNameFromReasoning, // ✅ Real supplier name from PO creation
|
||||
});
|
||||
|
||||
// Prioritize supplier name from alert reasoning (has actual name in demo data)
|
||||
const supplierName = reasoningInfo?.supplier_name_from_alert || // ✅ NOW WORKS!
|
||||
supplierMap.get(po.supplier_id) ||
|
||||
po.supplier_name;
|
||||
```
|
||||
|
||||
## Why This Fix Works
|
||||
|
||||
The **PO reasoning data is created during PO seeding**, not during alert seeding. When POs are created in `seed_demo_purchase_orders.py`, the code has access to the real supplier objects:
|
||||
|
||||
```python
|
||||
# Line 490: Get suppliers using XOR pattern
|
||||
suppliers = get_demo_supplier_ids(tenant_id)
|
||||
|
||||
# Line 498: Use supplier with correct ID and name
|
||||
supplier_high_trust = high_trust_suppliers[0] if high_trust_suppliers else suppliers[0]
|
||||
|
||||
# Lines 533-545: Create PO with supplier reference
|
||||
po3 = await create_purchase_order(
|
||||
db, tenant_id, supplier_high_trust, # ✅ Has correct ID and name
|
||||
PurchaseOrderStatus.pending_approval,
|
||||
Decimal("450.00"),
|
||||
# ...
|
||||
)
|
||||
|
||||
# Line 336: Reasoning data includes real supplier name
|
||||
reasoning_data = create_po_reasoning_low_stock(
|
||||
supplier_name=supplier.name, # ✅ "Lácteos del Valle S.A."
|
||||
# ...
|
||||
)
|
||||
```
|
||||
|
||||
## Why the Alert Seeder Doesn't Matter (For This Issue)
|
||||
|
||||
The alert seeder (`seed_enriched_alert_demo.py`) creates generic demo alerts with placeholder IDs, but these are NOT used for the PO approval alerts we see in the dashboard.
|
||||
|
||||
The **actual PO approval alerts are created automatically** by the procurement service when POs are created, and those alerts include the correct reasoning data with real supplier names.
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | Supplier ID Source | Status |
|
||||
|-----------|-------------------|--------|
|
||||
| **Supplier Seed** | XOR(tenant_id, base_supplier_id) | ✅ Correct UUID |
|
||||
| **PO Seed** | XOR(tenant_id, base_supplier_id) | ✅ Correct UUID |
|
||||
| **PO Reasoning Data** | `supplier.name` (real name) | ✅ "Lácteos del Valle S.A." |
|
||||
| **Alert Seed** | Hardcoded string "supplier-levadura-fresh" | ❌ Wrong format (but not used for PO alerts) |
|
||||
| **Session Clone** | XOR(virtual_tenant_id, base_supplier_id) | ✅ Correct UUID |
|
||||
| **Frontend Lookup** | `supplierMap.get(po.supplier_id)` | ❌ Fails (ID mismatch in demo) |
|
||||
| **Frontend Fix** | `reasoningInfo?.supplier_name_from_alert` | ✅ WORKS! Gets name from PO reasoning |
|
||||
|
||||
## Verification
|
||||
|
||||
The fix should now work because:
|
||||
1. ✅ POs are created with `reasoning_data` containing `supplier_name` parameter
|
||||
2. ✅ Frontend extracts `supplier_name` from `reasoning_data.parameters.supplier_name`
|
||||
3. ✅ Frontend prioritizes this value over ID lookup
|
||||
4. ✅ User should now see "Lácteos del Valle S.A." instead of "Unknown"
|
||||
|
||||
## Long-term Fix (Optional)
|
||||
|
||||
To fully resolve the underlying issue, the alert seeder should be updated to use proper XOR-based UUID generation instead of hardcoded string IDs:
|
||||
|
||||
```python
|
||||
# In seed_enriched_alert_demo.py, replace lines 40-46 with:
|
||||
|
||||
# Demo tenant ID (should match existing demo tenant)
|
||||
DEMO_TENANT_ID = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
|
||||
|
||||
# Base IDs matching suppliers seed
|
||||
BASE_SUPPLIER_MOLINOS = uuid.UUID("40000000-0000-0000-0000-000000000001")
|
||||
BASE_SUPPLIER_LACTEOS = uuid.UUID("40000000-0000-0000-0000-000000000002")
|
||||
|
||||
# Generate tenant-specific IDs using XOR
|
||||
tenant_int = int(DEMO_TENANT_ID.hex, 16)
|
||||
MOLINOS_SUPPLIER_ID = uuid.UUID(int=tenant_int ^ int(BASE_SUPPLIER_MOLINOS.hex, 16))
|
||||
LACTEOS_SUPPLIER_ID = uuid.UUID(int=tenant_int ^ int(BASE_SUPPLIER_LACTEOS.hex, 16))
|
||||
```
|
||||
|
||||
However, this is not necessary for fixing the current dashboard issue, as PO alerts use the correct reasoning data from PO creation.
|
||||
57
Tiltfile
57
Tiltfile
@@ -336,63 +336,6 @@ k8s_resource('external-data-init', resource_deps=['external-migration', 'redis']
|
||||
k8s_resource('nominatim-init', labels=['04-data-init'])
|
||||
|
||||
# =============================================================================
|
||||
# DEMO SEED JOBS - PHASE 1: FOUNDATION
|
||||
# =============================================================================
|
||||
|
||||
# Identity & Access (Weight 5-15)
|
||||
k8s_resource('demo-seed-users', resource_deps=['auth-migration'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-tenants', resource_deps=['tenant-migration', 'demo-seed-users'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-tenant-members', resource_deps=['tenant-migration', 'demo-seed-tenants', 'demo-seed-users'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-subscriptions', resource_deps=['tenant-migration', 'demo-seed-tenants'], labels=['05-demo-foundation'])
|
||||
k8s_resource('tenant-seed-pilot-coupon', resource_deps=['tenant-migration'], labels=['05-demo-foundation'])
|
||||
|
||||
# Core Data (Weight 15-20)
|
||||
k8s_resource('demo-seed-inventory', resource_deps=['inventory-migration', 'demo-seed-tenants'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-recipes', resource_deps=['recipes-migration', 'demo-seed-inventory'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-suppliers', resource_deps=['suppliers-migration', 'demo-seed-inventory'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-sales', resource_deps=['sales-migration', 'demo-seed-inventory'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-ai-models', resource_deps=['training-migration', 'demo-seed-inventory'], labels=['05-demo-foundation'])
|
||||
k8s_resource('demo-seed-stock', resource_deps=['inventory-migration', 'demo-seed-inventory'], labels=['05-demo-foundation'])
|
||||
|
||||
# =============================================================================
|
||||
# DEMO SEED JOBS - PHASE 2: OPERATIONS
|
||||
# =============================================================================
|
||||
|
||||
# Production & Quality (Weight 22-30)
|
||||
k8s_resource('demo-seed-quality-templates', resource_deps=['production-migration', 'demo-seed-tenants'], labels=['06-demo-operations'])
|
||||
k8s_resource('demo-seed-equipment', resource_deps=['production-migration', 'demo-seed-tenants', 'demo-seed-quality-templates'], labels=['06-demo-operations'])
|
||||
k8s_resource('demo-seed-production-batches', resource_deps=['production-migration', 'demo-seed-recipes', 'demo-seed-equipment'], labels=['06-demo-operations'])
|
||||
|
||||
# Orders & Customers (Weight 25-30)
|
||||
k8s_resource('demo-seed-customers', resource_deps=['orders-migration', 'demo-seed-tenants'], labels=['06-demo-operations'])
|
||||
k8s_resource('demo-seed-orders', resource_deps=['orders-migration', 'demo-seed-customers'], labels=['06-demo-operations'])
|
||||
|
||||
# Procurement & Planning (Weight 35-40)
|
||||
k8s_resource('demo-seed-procurement-plans', resource_deps=['procurement-migration', 'demo-seed-tenants'], labels=['06-demo-operations'])
|
||||
k8s_resource('demo-seed-purchase-orders', resource_deps=['procurement-migration', 'demo-seed-tenants'], labels=['06-demo-operations'])
|
||||
k8s_resource('demo-seed-forecasts', resource_deps=['forecasting-migration', 'demo-seed-tenants'], labels=['06-demo-operations'])
|
||||
|
||||
# Point of Sale
|
||||
k8s_resource('demo-seed-pos-configs', resource_deps=['demo-seed-tenants'], labels=['06-demo-operations'])
|
||||
|
||||
# =============================================================================
|
||||
# DEMO SEED JOBS - PHASE 3: INTELLIGENCE & ORCHESTRATION
|
||||
# =============================================================================
|
||||
|
||||
k8s_resource('demo-seed-orchestration-runs', resource_deps=['orchestrator-migration', 'demo-seed-tenants'], labels=['07-demo-intelligence'])
|
||||
|
||||
# =============================================================================
|
||||
# DEMO SEED JOBS - PHASE 4: ENTERPRISE (RETAIL LOCATIONS)
|
||||
# =============================================================================
|
||||
|
||||
k8s_resource('demo-seed-inventory-retail', resource_deps=['inventory-migration', 'demo-seed-inventory'], labels=['08-demo-enterprise'])
|
||||
k8s_resource('demo-seed-stock-retail', resource_deps=['inventory-migration', 'demo-seed-inventory-retail'], labels=['08-demo-enterprise'])
|
||||
k8s_resource('demo-seed-sales-retail', resource_deps=['sales-migration', 'demo-seed-stock-retail'], labels=['08-demo-enterprise'])
|
||||
k8s_resource('demo-seed-customers-retail', resource_deps=['orders-migration', 'demo-seed-sales-retail'], labels=['08-demo-enterprise'])
|
||||
k8s_resource('demo-seed-pos-retail', resource_deps=['pos-migration', 'demo-seed-customers-retail'], labels=['08-demo-enterprise'])
|
||||
k8s_resource('demo-seed-forecasts-retail', resource_deps=['forecasting-migration', 'demo-seed-pos-retail'], labels=['08-demo-enterprise'])
|
||||
k8s_resource('demo-seed-distribution-history', resource_deps=['distribution-migration'], labels=['08-demo-enterprise'])
|
||||
|
||||
# =============================================================================
|
||||
# APPLICATION SERVICES
|
||||
# =============================================================================
|
||||
|
||||
@@ -46,10 +46,43 @@ function AppContent() {
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
// Default toast options
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
background: 'white',
|
||||
color: 'black',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
minWidth: '300px',
|
||||
},
|
||||
success: {
|
||||
style: {
|
||||
background: '#f0fdf4', // bg-green-50 equivalent
|
||||
color: '#166534', // text-green-800 equivalent
|
||||
border: '1px solid #bbf7d0', // border-green-200 equivalent
|
||||
},
|
||||
},
|
||||
error: {
|
||||
style: {
|
||||
background: '#fef2f2', // bg-red-50 equivalent
|
||||
color: '#b91c1c', // text-red-800 equivalent
|
||||
border: '1px solid #fecaca', // border-red-200 equivalent
|
||||
},
|
||||
},
|
||||
warning: {
|
||||
style: {
|
||||
background: '#fffbf0', // bg-yellow-50 equivalent
|
||||
color: '#92400e', // text-yellow-800 equivalent
|
||||
border: '1px solid #fde68a', // border-yellow-200 equivalent
|
||||
},
|
||||
},
|
||||
info: {
|
||||
style: {
|
||||
background: '#eff6ff', // bg-blue-50 equivalent
|
||||
color: '#1e40af', // text-blue-800 equivalent
|
||||
border: '1px solid #bfdbfe', // border-blue-200 equivalent
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as orchestratorService from '../services/orchestrator';
|
||||
import { suppliersService } from '../services/suppliers';
|
||||
import { useBatchNotifications, useDeliveryNotifications, useOrchestrationNotifications } from '../../hooks/useEventNotifications';
|
||||
import { useSSEEvents } from '../../hooks/useSSE';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -27,6 +28,7 @@ export interface DashboardData {
|
||||
productionBatches: any[];
|
||||
deliveries: any[];
|
||||
orchestrationSummary: OrchestrationSummary | null;
|
||||
aiInsights: any[]; // AI-generated insights for professional/enterprise tiers
|
||||
|
||||
// Computed/derived data
|
||||
preventedIssues: any[];
|
||||
@@ -70,7 +72,8 @@ export function useDashboardData(tenantId: string) {
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
queryFn: async () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const now = new Date();
|
||||
const now = new Date(); // Keep for local time display
|
||||
const nowUTC = new Date(); // UTC time for accurate comparison with API dates
|
||||
|
||||
// Parallel fetch ALL data needed by all 4 blocks (including suppliers for PO enrichment)
|
||||
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers] = await Promise.all([
|
||||
@@ -158,20 +161,20 @@ export function useDashboardData(tenantId: string) {
|
||||
|
||||
const overdueDeliveries = deliveries.filter((d: any) => {
|
||||
if (!isPending(d.status)) return false;
|
||||
const expectedDate = new Date(d.expected_delivery_date);
|
||||
return expectedDate < now;
|
||||
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
||||
return expectedDate < nowUTC;
|
||||
}).map((d: any) => ({
|
||||
...d,
|
||||
hoursOverdue: Math.ceil((now.getTime() - new Date(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
|
||||
hoursOverdue: Math.ceil((nowUTC.getTime() - parseISO(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
const pendingDeliveriesFiltered = deliveries.filter((d: any) => {
|
||||
if (!isPending(d.status)) return false;
|
||||
const expectedDate = new Date(d.expected_delivery_date);
|
||||
return expectedDate >= now;
|
||||
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
||||
return expectedDate >= nowUTC;
|
||||
}).map((d: any) => ({
|
||||
...d,
|
||||
hoursUntil: Math.ceil((new Date(d.expected_delivery_date).getTime() - now.getTime()) / (1000 * 60 * 60)),
|
||||
hoursUntil: Math.ceil((parseISO(d.expected_delivery_date).getTime() - nowUTC.getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
// Filter production batches by status
|
||||
@@ -180,10 +183,10 @@ export function useDashboardData(tenantId: string) {
|
||||
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||
const plannedStart = b.planned_start_time;
|
||||
if (!plannedStart) return false;
|
||||
return new Date(plannedStart) < now;
|
||||
return parseISO(plannedStart) < nowUTC;
|
||||
}).map((b: any) => ({
|
||||
...b,
|
||||
hoursLate: Math.ceil((now.getTime() - new Date(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
|
||||
hoursLate: Math.ceil((nowUTC.getTime() - parseISO(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
|
||||
}));
|
||||
|
||||
const runningBatches = productionBatches.filter((b: any) =>
|
||||
@@ -195,7 +198,32 @@ export function useDashboardData(tenantId: string) {
|
||||
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
|
||||
const plannedStart = b.planned_start_time;
|
||||
if (!plannedStart) return true; // No planned start, count as pending
|
||||
return new Date(plannedStart) >= now;
|
||||
return parseISO(plannedStart) >= nowUTC;
|
||||
});
|
||||
|
||||
// Create set of batch IDs that we already show in the UI (late or running)
|
||||
const lateBatchIds = new Set(lateToStartBatches.map((b: any) => b.id));
|
||||
const runningBatchIds = new Set(runningBatches.map((b: any) => b.id));
|
||||
|
||||
// Filter alerts to exclude those for batches already shown in the UI
|
||||
// This prevents duplicate display: batch card + separate alert for the same batch
|
||||
const deduplicatedAlerts = alerts.filter((a: any) => {
|
||||
const eventType = a.event_type || '';
|
||||
const batchId = a.event_metadata?.batch_id || a.entity_links?.production_batch;
|
||||
|
||||
if (!batchId) return true; // Keep alerts not related to batches
|
||||
|
||||
// Filter out batch_start_delayed alerts for batches shown in "late to start" section
|
||||
if (eventType.includes('batch_start_delayed') && lateBatchIds.has(batchId)) {
|
||||
return false; // Already shown as late batch
|
||||
}
|
||||
|
||||
// Filter out production_delay alerts for batches shown in "running" section
|
||||
if (eventType.includes('production_delay') && runningBatchIds.has(batchId)) {
|
||||
return false; // Already shown as running batch (with progress bar showing delay)
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build orchestration summary
|
||||
@@ -218,11 +246,12 @@ export function useDashboardData(tenantId: string) {
|
||||
|
||||
return {
|
||||
// Raw data
|
||||
alerts,
|
||||
alerts: deduplicatedAlerts,
|
||||
pendingPOs: enrichedPendingPOs,
|
||||
productionBatches,
|
||||
deliveries,
|
||||
orchestrationSummary,
|
||||
aiInsights: [], // AI-generated insights for professional/enterprise tiers
|
||||
|
||||
// Computed
|
||||
preventedIssues,
|
||||
@@ -283,7 +312,7 @@ export function useDashboardRealtimeSync(tenantId: string) {
|
||||
if (deliveryNotifications.length === 0 || !tenantId) return;
|
||||
|
||||
const latest = deliveryNotifications[0];
|
||||
if (['delivery_received', 'delivery_overdue'].includes(latest.event_type)) {
|
||||
if (['delivery_received', 'delivery_overdue', 'delivery_arriving_soon', 'stock_receipt_incomplete'].includes(latest.event_type)) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['dashboard-data', tenantId],
|
||||
refetchType: 'active',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ProcurementService } from '../services/procurement-service';
|
||||
import * as orchestratorService from '../services/orchestrator'; // Only for orchestration run info
|
||||
import { ProductionStatus } from '../types/production';
|
||||
import { apiClient } from '../client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -327,7 +328,8 @@ export function useSharedDashboardData(tenantId: string) {
|
||||
]);
|
||||
|
||||
// Calculate late-to-start batches (batches that should have started but haven't)
|
||||
const now = new Date();
|
||||
const now = new Date(); // Local time for display
|
||||
const nowUTC = new Date(); // UTC time for accurate comparison with API dates
|
||||
const allBatches = prodBatches?.batches || [];
|
||||
const lateToStart = allBatches.filter((b: any) => {
|
||||
// Only check PENDING or SCHEDULED batches (not started yet)
|
||||
@@ -338,16 +340,18 @@ export function useSharedDashboardData(tenantId: string) {
|
||||
if (!plannedStart) return false;
|
||||
|
||||
// Check if planned start time is in the past (late to start)
|
||||
return new Date(plannedStart) < now;
|
||||
return parseISO(plannedStart) < nowUTC;
|
||||
});
|
||||
|
||||
// Calculate overdue deliveries (pending deliveries with past due date)
|
||||
const allDelivs = deliveries?.deliveries || [];
|
||||
const isPending = (s: string) =>
|
||||
s === 'PENDING' || s === 'sent_to_supplier' || s === 'confirmed';
|
||||
const overdueDelivs = allDelivs.filter((d: any) =>
|
||||
isPending(d.status) && new Date(d.expected_delivery_date) < now
|
||||
);
|
||||
// FIX: Use UTC timestamps for consistent time zone handling
|
||||
const overdueDelivs = allDelivs.filter((d: any) => {
|
||||
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
||||
return isPending(d.status) && expectedDate.getTime() < nowUTC.getTime();
|
||||
});
|
||||
|
||||
return {
|
||||
overdueDeliveries: overdueDelivs.length,
|
||||
@@ -1019,7 +1023,7 @@ export function useExecutionProgress(tenantId: string) {
|
||||
|
||||
if (!aTime || !bTime) return 0;
|
||||
|
||||
return new Date(aTime).getTime() - new Date(bTime).getTime();
|
||||
return parseISO(aTime).getTime() - parseISO(bTime).getTime();
|
||||
});
|
||||
|
||||
const nextBatchDetail = sortedPendingBatches.length > 0 ? {
|
||||
@@ -1065,10 +1069,12 @@ export function useExecutionProgress(tenantId: string) {
|
||||
const pendingDeliveriesData = allDeliveries.filter((d: any) => isPending(d.status));
|
||||
|
||||
// Identify overdue deliveries (pending deliveries with past due date)
|
||||
// FIX: Use UTC timestamps to avoid time zone issues
|
||||
const overdueDeliveriesData = pendingDeliveriesData.filter((d: any) => {
|
||||
const expectedDate = new Date(d.expected_delivery_date);
|
||||
const now = new Date();
|
||||
return expectedDate < now;
|
||||
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
||||
const nowUTC = new Date(); // UTC time for accurate comparison
|
||||
// Compare UTC timestamps instead of local time
|
||||
return expectedDate.getTime() < nowUTC.getTime();
|
||||
});
|
||||
|
||||
// Calculate counts
|
||||
@@ -1080,17 +1086,17 @@ export function useExecutionProgress(tenantId: string) {
|
||||
// Convert raw delivery data to the expected format for the UI
|
||||
const processedDeliveries = allDeliveries.map((d: any) => {
|
||||
const itemCount = d.line_items?.length || 0;
|
||||
const expectedDate = new Date(d.expected_delivery_date);
|
||||
const now = new Date();
|
||||
const expectedDate = parseISO(d.expected_delivery_date); // Proper UTC parsing
|
||||
const nowUTC = new Date(); // UTC time for accurate comparison
|
||||
let hoursUntil = 0;
|
||||
let hoursOverdue = 0;
|
||||
|
||||
if (expectedDate < now) {
|
||||
if (expectedDate < nowUTC) {
|
||||
// Calculate hours overdue
|
||||
hoursOverdue = Math.ceil((now.getTime() - expectedDate.getTime()) / (1000 * 60 * 60));
|
||||
hoursOverdue = Math.ceil((nowUTC.getTime() - expectedDate.getTime()) / (1000 * 60 * 60));
|
||||
} else {
|
||||
// Calculate hours until delivery
|
||||
hoursUntil = Math.ceil((expectedDate.getTime() - now.getTime()) / (1000 * 60 * 60));
|
||||
hoursUntil = Math.ceil((expectedDate.getTime() - nowUTC.getTime()) / (1000 * 60 * 60));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1110,9 +1116,18 @@ export function useExecutionProgress(tenantId: string) {
|
||||
});
|
||||
|
||||
// Separate into specific lists for the UI
|
||||
// FIX: Use UTC timestamps for consistent time zone handling
|
||||
const receivedDeliveriesList = processedDeliveries.filter((d: any) => isDelivered(d.status));
|
||||
const pendingDeliveriesList = processedDeliveries.filter((d: any) => isPending(d.status) && new Date(d.expectedDeliveryDate) >= new Date());
|
||||
const overdueDeliveriesList = processedDeliveries.filter((d: any) => isPending(d.status) && new Date(d.expectedDeliveryDate) < new Date());
|
||||
const pendingDeliveriesList = processedDeliveries.filter((d: any) => {
|
||||
const expectedDate = new Date(d.expectedDeliveryDate);
|
||||
const now = new Date();
|
||||
return isPending(d.status) && expectedDate.getTime() >= now.getTime();
|
||||
});
|
||||
const overdueDeliveriesList = processedDeliveries.filter((d: any) => {
|
||||
const expectedDate = new Date(d.expectedDeliveryDate);
|
||||
const now = new Date();
|
||||
return isPending(d.status) && expectedDate.getTime() < now.getTime();
|
||||
});
|
||||
|
||||
// Determine delivery status
|
||||
let deliveryStatus: 'no_deliveries' | 'completed' | 'on_track' | 'at_risk' = 'no_deliveries';
|
||||
|
||||
@@ -142,6 +142,7 @@ export interface ProductionBatchResponse {
|
||||
quality_notes: string | null;
|
||||
delay_reason: string | null;
|
||||
cancellation_reason: string | null;
|
||||
reasoning_data?: Record<string, any> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at: string | null;
|
||||
|
||||
181
frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx
Normal file
181
frontend/src/components/dashboard/blocks/AIInsightsBlock.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* AIInsightsBlock - AI Insights Dashboard Block
|
||||
*
|
||||
* Displays AI-generated insights for professional/enterprise tiers
|
||||
* Shows top 2-3 insights with links to full AI Insights page
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Lightbulb, ArrowRight, BarChart2, TrendingUp, TrendingDown, Shield, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface AIInsight {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'cost_optimization' | 'waste_reduction' | 'safety_stock' | 'demand_forecast' | 'risk_alert';
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
impact_value?: string;
|
||||
impact_currency?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AIInsightsBlockProps {
|
||||
insights: AIInsight[];
|
||||
loading?: boolean;
|
||||
onViewAll: () => void;
|
||||
}
|
||||
|
||||
export function AIInsightsBlock({ insights = [], loading = false, onViewAll }: AIInsightsBlockProps) {
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
// Get icon based on insight type
|
||||
const getInsightIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'cost_optimization': return <TrendingUp className="w-5 h-5 text-[var(--color-success-600)]" />;
|
||||
case 'waste_reduction': return <TrendingDown className="w-5 h-5 text-[var(--color-success-600)]" />;
|
||||
case 'safety_stock': return <Shield className="w-5 h-5 text-[var(--color-info-600)]" />;
|
||||
case 'demand_forecast': return <BarChart2 className="w-5 h-5 text-[var(--color-primary-600)]" />;
|
||||
case 'risk_alert': return <AlertTriangle className="w-5 h-5 text-[var(--color-error-600)]" />;
|
||||
default: return <Lightbulb className="w-5 h-5 text-[var(--color-primary-600)]" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get impact color based on level
|
||||
const getImpactColor = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high': return 'bg-[var(--color-error-100)] text-[var(--color-error-700)]';
|
||||
case 'medium': return 'bg-[var(--color-warning-100)] text-[var(--color-warning-700)]';
|
||||
case 'low': return 'bg-[var(--color-info-100)] text-[var(--color-info-700)]';
|
||||
default: return 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]';
|
||||
}
|
||||
};
|
||||
|
||||
// Get impact label
|
||||
const getImpactLabel = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high': return t('dashboard:ai_insights.impact_high');
|
||||
case 'medium': return t('dashboard:ai_insights.impact_medium');
|
||||
case 'low': return t('dashboard:ai_insights.impact_low');
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-[var(--bg-secondary)] rounded-full"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 bg-[var(--bg-secondary)] rounded w-1/3"></div>
|
||||
<div className="h-4 bg-[var(--bg-secondary)] rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||
<div className="h-16 bg-[var(--bg-secondary)] rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show top 3 insights
|
||||
const topInsights = insights.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl shadow-lg border border-[var(--border-primary)] bg-[var(--bg-primary)] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Icon */}
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 bg-[var(--color-primary-100)]">
|
||||
<Lightbulb className="w-6 h-6 text-[var(--color-primary-600)]" />
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-[var(--text-primary)]">
|
||||
{t('dashboard:ai_insights.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:ai_insights.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View All Button */}
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border-primary)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors text-sm font-medium"
|
||||
>
|
||||
<span>{t('dashboard:ai_insights.view_all')}</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights List */}
|
||||
{topInsights.length > 0 ? (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
{topInsights.map((insight, index) => (
|
||||
<div
|
||||
key={insight.id || index}
|
||||
className={`p-4 ${index < topInsights.length - 1 ? 'border-b border-[var(--border-primary)]' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{getInsightIcon(insight.type)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-[var(--text-primary)] text-sm">
|
||||
{insight.title}
|
||||
</h3>
|
||||
{/* Impact Badge */}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getImpactColor(insight.impact)}`}>
|
||||
{getImpactLabel(insight.impact)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
|
||||
{/* Impact Value */}
|
||||
{insight.impact_value && (
|
||||
<div className="flex items-center gap-2">
|
||||
{insight.type === 'cost_optimization' && (
|
||||
<span className="text-sm font-semibold text-[var(--color-success-600)]">
|
||||
{insight.impact_currency}{insight.impact_value} {t('dashboard:ai_insights.savings')}
|
||||
</span>
|
||||
)}
|
||||
{insight.type === 'waste_reduction' && (
|
||||
<span className="text-sm font-semibold text-[var(--color-success-600)]">
|
||||
{insight.impact_value} {t('dashboard:ai_insights.reduction')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-[var(--color-info-50)] border border-[var(--color-info-100)]">
|
||||
<Lightbulb className="w-6 h-6 text-[var(--color-info-600)]" />
|
||||
<p className="text-sm text-[var(--color-info-700)]">
|
||||
{t('dashboard:ai_insights.no_insights')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AIInsightsBlock;
|
||||
@@ -26,6 +26,7 @@ interface ProductionStatusBlockProps {
|
||||
lateToStartBatches?: any[];
|
||||
runningBatches?: any[];
|
||||
pendingBatches?: any[];
|
||||
alerts?: any[]; // Add alerts prop for production-related alerts
|
||||
onStartBatch?: (batchId: string) => Promise<void>;
|
||||
onViewBatch?: (batchId: string) => void;
|
||||
loading?: boolean;
|
||||
@@ -35,6 +36,7 @@ export function ProductionStatusBlock({
|
||||
lateToStartBatches = [],
|
||||
runningBatches = [],
|
||||
pendingBatches = [],
|
||||
alerts = [],
|
||||
onStartBatch,
|
||||
onViewBatch,
|
||||
loading,
|
||||
@@ -43,6 +45,28 @@ export function ProductionStatusBlock({
|
||||
const [expandedReasoningId, setExpandedReasoningId] = useState<string | null>(null);
|
||||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||||
|
||||
// Filter production-related alerts and deduplicate by ID
|
||||
const productionAlerts = React.useMemo(() => {
|
||||
const filtered = alerts.filter((alert: any) => {
|
||||
const eventType = alert.event_type || '';
|
||||
return eventType.includes('production.') ||
|
||||
eventType.includes('equipment_maintenance') ||
|
||||
eventType.includes('production_delay') ||
|
||||
eventType.includes('batch_start_delayed');
|
||||
});
|
||||
|
||||
// Deduplicate by alert ID to prevent duplicates from API + SSE
|
||||
const uniqueAlerts = new Map<string, any>();
|
||||
filtered.forEach((alert: any) => {
|
||||
const alertId = alert.id || alert.event_id;
|
||||
if (alertId && !uniqueAlerts.has(alertId)) {
|
||||
uniqueAlerts.set(alertId, alert);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueAlerts.values());
|
||||
}, [alerts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl shadow-lg p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)] animate-pulse">
|
||||
@@ -64,11 +88,12 @@ export function ProductionStatusBlock({
|
||||
const hasLate = lateToStartBatches.length > 0;
|
||||
const hasRunning = runningBatches.length > 0;
|
||||
const hasPending = pendingBatches.length > 0;
|
||||
const hasAnyProduction = hasLate || hasRunning || hasPending;
|
||||
const hasAlerts = productionAlerts.length > 0;
|
||||
const hasAnyProduction = hasLate || hasRunning || hasPending || hasAlerts;
|
||||
const totalCount = lateToStartBatches.length + runningBatches.length + pendingBatches.length;
|
||||
|
||||
// Determine header status
|
||||
const status = hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
|
||||
// Determine header status - prioritize alerts and late batches
|
||||
const status = hasAlerts || hasLate ? 'error' : hasRunning ? 'info' : hasPending ? 'warning' : 'success';
|
||||
|
||||
const statusStyles = {
|
||||
success: {
|
||||
@@ -115,10 +140,19 @@ export function ProductionStatusBlock({
|
||||
if (typeof reasoningData === 'string') return reasoningData;
|
||||
|
||||
if (reasoningData.type === 'forecast_demand') {
|
||||
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', {
|
||||
product: reasoningData.parameters?.product_name || batch.product_name,
|
||||
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
|
||||
});
|
||||
// Check if this is enhanced reasoning with factors
|
||||
if (reasoningData.parameters?.factors && reasoningData.parameters.factors.length > 0) {
|
||||
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand_enhanced', {
|
||||
product: reasoningData.parameters?.product_name || batch.product_name,
|
||||
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
|
||||
variance: reasoningData.parameters?.variance_percent || 0,
|
||||
});
|
||||
} else {
|
||||
return t('dashboard:new_dashboard.production_status.reasoning.forecast_demand', {
|
||||
product: reasoningData.parameters?.product_name || batch.product_name,
|
||||
demand: reasoningData.parameters?.predicted_demand || batch.planned_quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (reasoningData.type === 'customer_order') {
|
||||
@@ -127,11 +161,79 @@ export function ProductionStatusBlock({
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (reasoningData.summary) return reasoningData.summary;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Get factor icon based on type (handles both uppercase and lowercase formats)
|
||||
const getFactorIcon = (factorType: string) => {
|
||||
const normalizedFactor = factorType?.toLowerCase() || '';
|
||||
if (normalizedFactor.includes('historical') || normalizedFactor === 'historical_pattern') return '📊';
|
||||
if (normalizedFactor.includes('sunny') || normalizedFactor === 'weather_sunny') return '☀️';
|
||||
if (normalizedFactor.includes('rainy') || normalizedFactor === 'weather_rainy') return '🌧️';
|
||||
if (normalizedFactor.includes('cold') || normalizedFactor === 'weather_cold') return '❄️';
|
||||
if (normalizedFactor.includes('hot') || normalizedFactor === 'weather_hot') return '🔥';
|
||||
if (normalizedFactor.includes('weekend') || normalizedFactor === 'weekend_boost') return '📅';
|
||||
if (normalizedFactor.includes('inventory') || normalizedFactor === 'inventory_level') return '📦';
|
||||
if (normalizedFactor.includes('seasonal') || normalizedFactor === 'seasonal_trend') return '🍂';
|
||||
return 'ℹ️';
|
||||
};
|
||||
|
||||
// Get factor translation key (handles both uppercase and lowercase formats)
|
||||
const getFactorTranslationKey = (factorType: string) => {
|
||||
const normalizedFactor = factorType?.toLowerCase().replace(/\s+/g, '_') || '';
|
||||
|
||||
// Direct mapping for exact matches
|
||||
const factorMap: Record<string, string> = {
|
||||
'historical_pattern': 'historical_pattern',
|
||||
'historical_sales_pattern': 'historical_pattern',
|
||||
'weather_sunny': 'weather_sunny',
|
||||
'weather_impact_sunny': 'weather_sunny',
|
||||
'weather_rainy': 'weather_rainy',
|
||||
'weather_cold': 'weather_cold',
|
||||
'weather_hot': 'weather_hot',
|
||||
'weekend_boost': 'weekend_boost',
|
||||
'inventory_level': 'inventory_level',
|
||||
'current_inventory_trigger': 'inventory_level',
|
||||
'seasonal_trend': 'seasonal_trend',
|
||||
'seasonal_trend_adjustment': 'seasonal_trend',
|
||||
};
|
||||
|
||||
// Check for direct match
|
||||
if (factorMap[normalizedFactor]) {
|
||||
return `dashboard:new_dashboard.production_status.factors.${factorMap[normalizedFactor]}`;
|
||||
}
|
||||
|
||||
// Fallback to partial matching
|
||||
if (normalizedFactor.includes('historical')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.historical_pattern';
|
||||
}
|
||||
if (normalizedFactor.includes('sunny')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.weather_sunny';
|
||||
}
|
||||
if (normalizedFactor.includes('rainy')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.weather_rainy';
|
||||
}
|
||||
if (normalizedFactor.includes('cold')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.weather_cold';
|
||||
}
|
||||
if (normalizedFactor.includes('hot')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.weather_hot';
|
||||
}
|
||||
if (normalizedFactor.includes('weekend')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.weekend_boost';
|
||||
}
|
||||
if (normalizedFactor.includes('inventory')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.inventory_level';
|
||||
}
|
||||
if (normalizedFactor.includes('seasonal')) {
|
||||
return 'dashboard:new_dashboard.production_status.factors.seasonal_trend';
|
||||
}
|
||||
return 'dashboard:new_dashboard.production_status.factors.general';
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (isoString: string | null | undefined) => {
|
||||
if (!isoString) return '--:--';
|
||||
@@ -153,6 +255,156 @@ export function ProductionStatusBlock({
|
||||
return Math.round(((now - start) / (end - start)) * 100);
|
||||
};
|
||||
|
||||
// Render an alert item
|
||||
const renderAlertItem = (alert: any, index: number, total: number) => {
|
||||
const alertId = alert.id || alert.event_id;
|
||||
const eventType = alert.event_type || '';
|
||||
const priorityLevel = alert.priority_level || 'standard';
|
||||
const businessImpact = alert.business_impact || {};
|
||||
const urgency = alert.urgency || {};
|
||||
const metadata = alert.event_metadata || {};
|
||||
|
||||
// Determine alert icon and type
|
||||
let icon = <AlertTriangle className="w-4 h-4 text-[var(--color-error-600)]" />;
|
||||
let alertTitle = '';
|
||||
let alertDescription = '';
|
||||
|
||||
if (eventType.includes('equipment_maintenance')) {
|
||||
icon = <AlertTriangle className="w-4 h-4 text-[var(--color-warning-600)]" />;
|
||||
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.equipment_maintenance');
|
||||
alertDescription = alert.description || alert.message || '';
|
||||
} else if (eventType.includes('production_delay')) {
|
||||
icon = <Clock className="w-4 h-4 text-[var(--color-error-600)]" />;
|
||||
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.production_delay');
|
||||
alertDescription = alert.description || alert.message || '';
|
||||
} else if (eventType.includes('batch_start_delayed')) {
|
||||
icon = <AlertTriangle className="w-4 h-4 text-[var(--color-warning-600)]" />;
|
||||
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.batch_delayed');
|
||||
alertDescription = alert.description || alert.message || '';
|
||||
} else {
|
||||
alertTitle = alert.title || t('dashboard:new_dashboard.production_status.alerts.generic');
|
||||
alertDescription = alert.description || alert.message || '';
|
||||
}
|
||||
|
||||
// Priority badge styling
|
||||
const priorityStyles = {
|
||||
critical: 'bg-[var(--color-error-100)] text-[var(--color-error-700)]',
|
||||
important: 'bg-[var(--color-warning-100)] text-[var(--color-warning-700)]',
|
||||
standard: 'bg-[var(--color-info-100)] text-[var(--color-info-700)]',
|
||||
info: 'bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]',
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
if (diffMins < 1) return t('common:time.just_now');
|
||||
if (diffMins < 60) return t('common:time.minutes_ago', { count: diffMins });
|
||||
if (diffHours < 24) return t('common:time.hours_ago', { count: diffHours });
|
||||
return t('common:time.days_ago', { count: Math.floor(diffHours / 24) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alertId || index}
|
||||
className={`p-4 ${index < total - 1 ? 'border-b border-[var(--border-primary)]' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
{icon}
|
||||
<span className="font-semibold text-[var(--text-primary)]">
|
||||
{alertTitle}
|
||||
</span>
|
||||
<div className={`px-2 py-0.5 rounded-full text-xs font-medium ${priorityStyles[priorityLevel as keyof typeof priorityStyles] || priorityStyles.standard}`}>
|
||||
{t(`dashboard:new_dashboard.production_status.priority.${priorityLevel}`)}
|
||||
</div>
|
||||
{alert.created_at && (
|
||||
<span className="text-xs text-[var(--text-tertiary)]">
|
||||
{formatTimeAgo(alert.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{alertDescription}
|
||||
</p>
|
||||
|
||||
{/* Additional Details */}
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{/* Business Impact */}
|
||||
{businessImpact.affected_orders > 0 && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
|
||||
<span>📦</span>
|
||||
<span>
|
||||
{t('dashboard:new_dashboard.production_status.alerts.affected_orders', {
|
||||
count: businessImpact.affected_orders
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{businessImpact.production_delay_hours > 0 && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{t('dashboard:new_dashboard.production_status.alerts.delay_hours', {
|
||||
hours: Math.round(businessImpact.production_delay_hours * 10) / 10
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{businessImpact.financial_impact_eur > 0 && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
|
||||
<span>💰</span>
|
||||
<span>
|
||||
{t('dashboard:new_dashboard.production_status.alerts.financial_impact', {
|
||||
amount: Math.round(businessImpact.financial_impact_eur)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Urgency Info */}
|
||||
{urgency.hours_until_consequence !== undefined && urgency.hours_until_consequence < 24 && (
|
||||
<div className="flex items-center gap-1 text-[var(--color-warning-600)] font-medium">
|
||||
<Timer className="w-3 h-3" />
|
||||
<span>
|
||||
{t('dashboard:new_dashboard.production_status.alerts.urgent_in', {
|
||||
hours: Math.round(urgency.hours_until_consequence * 10) / 10
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product/Batch Info from metadata */}
|
||||
{metadata.product_name && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
|
||||
<span>🥖</span>
|
||||
<span>{metadata.product_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadata.batch_number && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-tertiary)]">
|
||||
<span>#</span>
|
||||
<span>{metadata.batch_number}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a batch item
|
||||
const renderBatchItem = (batch: any, type: 'late' | 'running' | 'pending', index: number, total: number) => {
|
||||
const batchId = batch.id || batch.batch_id;
|
||||
@@ -289,11 +541,123 @@ export function ProductionStatusBlock({
|
||||
<div className="mt-3 p-3 rounded-lg bg-[var(--color-primary-50)] border border-[var(--color-primary-100)]">
|
||||
<div className="flex items-start gap-2">
|
||||
<Brain className="w-4 h-4 text-[var(--color-primary-600)] mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm font-medium text-[var(--color-primary-700)] mb-1">
|
||||
{t('dashboard:new_dashboard.production_status.ai_reasoning')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--color-primary-600)]">{reasoning}</p>
|
||||
<p className="text-sm text-[var(--color-primary-600)] mb-2">{reasoning}</p>
|
||||
|
||||
{/* Weather Data Display */}
|
||||
{batch.reasoning_data?.parameters?.weather_data && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-[var(--bg-primary)] border border-[var(--color-primary-200)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">
|
||||
{batch.reasoning_data.parameters.weather_data.condition === 'sunny' && '☀️'}
|
||||
{batch.reasoning_data.parameters.weather_data.condition === 'rainy' && '🌧️'}
|
||||
{batch.reasoning_data.parameters.weather_data.condition === 'cold' && '❄️'}
|
||||
{batch.reasoning_data.parameters.weather_data.condition === 'hot' && '🔥'}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)] uppercase">
|
||||
{t('dashboard:new_dashboard.production_status.weather_forecast')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t(`dashboard:new_dashboard.production_status.weather_conditions.${batch.reasoning_data.parameters.weather_data.condition}`, {
|
||||
temp: batch.reasoning_data.parameters.weather_data.temperature,
|
||||
humidity: batch.reasoning_data.parameters.weather_data.humidity
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{t('dashboard:new_dashboard.production_status.demand_impact')}
|
||||
</p>
|
||||
<p className={`text-sm font-semibold ${
|
||||
batch.reasoning_data.parameters.weather_data.impact_factor > 1
|
||||
? 'text-[var(--color-success-600)]'
|
||||
: 'text-[var(--color-warning-600)]'
|
||||
}`}>
|
||||
{batch.reasoning_data.parameters.weather_data.impact_factor > 1 ? '+' : ''}
|
||||
{Math.round((batch.reasoning_data.parameters.weather_data.impact_factor - 1) * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced reasoning with factors */}
|
||||
{batch.reasoning_data?.parameters?.factors && batch.reasoning_data.parameters.factors.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-xs font-medium text-[var(--color-primary-800)] uppercase tracking-wide">
|
||||
{t('dashboard:new_dashboard.production_status.factors_title')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{batch.reasoning_data.parameters.factors.map((factor: any, factorIndex: number) => (
|
||||
<div key={factorIndex} className="flex items-center gap-2">
|
||||
<span className="text-lg">{getFactorIcon(factor.factor)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)]">
|
||||
{t(getFactorTranslationKey(factor.factor))}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">
|
||||
({Math.round(factor.weight * 100)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-[var(--color-primary-500)] h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${Math.round(factor.weight * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${
|
||||
factor.contribution >= 0
|
||||
? 'text-[var(--color-success-600)]'
|
||||
: 'text-[var(--color-error-600)]'
|
||||
}`}>
|
||||
{factor.contribution >= 0 ? '+' : ''}{Math.round(factor.contribution)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Confidence and variance info */}
|
||||
<div className="mt-2 pt-2 border-t border-[var(--color-primary-100)] flex flex-wrap items-center gap-4 text-xs">
|
||||
{batch.reasoning_data.metadata?.confidence_score && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-secondary)]">
|
||||
<span className="text-[var(--color-primary-600)]">🎯</span>
|
||||
<span>
|
||||
{t('dashboard:new_dashboard.production_status.confidence', {
|
||||
confidence: Math.round(batch.reasoning_data.metadata.confidence_score * 100)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{batch.reasoning_data.parameters?.variance_percent && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-secondary)]">
|
||||
<span className="text-[var(--color-primary-600)]">📈</span>
|
||||
<span>
|
||||
{t('dashboard:new_dashboard.production_status.variance', {
|
||||
variance: batch.reasoning_data.parameters.variance_percent
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{batch.reasoning_data.parameters?.historical_average && (
|
||||
<div className="flex items-center gap-1 text-[var(--text-secondary)]">
|
||||
<span className="text-[var(--color-primary-600)]">📊</span>
|
||||
<span>
|
||||
{t('dashboard:new_dashboard.production_status.historical_avg', {
|
||||
avg: Math.round(batch.reasoning_data.parameters.historical_average)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,6 +718,21 @@ export function ProductionStatusBlock({
|
||||
{/* Content */}
|
||||
{hasAnyProduction ? (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
{/* Production Alerts Section */}
|
||||
{hasAlerts && (
|
||||
<div className="bg-[var(--color-error-50)]">
|
||||
<div className="px-6 py-3 border-b border-[var(--color-error-100)]">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-error-700)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t('dashboard:new_dashboard.production_status.alerts_section')}
|
||||
</h3>
|
||||
</div>
|
||||
{productionAlerts.map((alert, index) =>
|
||||
renderAlertItem(alert, index, productionAlerts.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Late to Start Section */}
|
||||
{hasLate && (
|
||||
<div className="bg-[var(--color-error-50)]">
|
||||
|
||||
@@ -66,8 +66,8 @@ export function SystemStatusBlock({ data, loading }: SystemStatusBlockProps) {
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMinutes < 1) return t('common:time.just_now', 'Just now');
|
||||
if (diffMinutes < 60) return t('common:time.minutes_ago', '{{count}} min ago', { count: diffMinutes });
|
||||
if (diffHours < 24) return t('common:time.hours_ago', '{{count}}h ago', { count: diffHours });
|
||||
if (diffMinutes < 60) return t('common:time.minutes_ago', '{count} min ago', { count: diffMinutes });
|
||||
if (diffHours < 24) return t('common:time.hours_ago', '{count}h ago', { count: diffHours });
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
@@ -8,3 +8,4 @@ export { SystemStatusBlock } from './SystemStatusBlock';
|
||||
export { PendingPurchasesBlock } from './PendingPurchasesBlock';
|
||||
export { PendingDeliveriesBlock } from './PendingDeliveriesBlock';
|
||||
export { ProductionStatusBlock } from './ProductionStatusBlock';
|
||||
export { AIInsightsBlock } from './AIInsightsBlock';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Eye } from 'lucide-react';
|
||||
import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Eye, Info } from 'lucide-react';
|
||||
import { StatusCard, StatusIndicatorConfig } from '../../ui/StatusCard/StatusCard';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
import { ProductionBatchResponse, ProductionStatus, ProductionPriority } from '../../../api/types/production';
|
||||
@@ -258,6 +258,39 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
|
||||
metadata.push(safeText(qualityInfo, qualityInfo, 50));
|
||||
}
|
||||
|
||||
// Add reasoning information if available
|
||||
if (batch.reasoning_data) {
|
||||
const { trigger_type, trigger_description, factors, consequence, confidence_score, variance, prediction_details } = batch.reasoning_data;
|
||||
|
||||
// Add trigger information
|
||||
if (trigger_type) {
|
||||
let triggerLabel = t(`reasoning:triggers.${trigger_type.toLowerCase()}`);
|
||||
if (triggerLabel === `reasoning:triggers.${trigger_type.toLowerCase()}`) {
|
||||
triggerLabel = trigger_type;
|
||||
}
|
||||
metadata.push(`Causa: ${triggerLabel}`);
|
||||
}
|
||||
|
||||
// Add factors
|
||||
if (factors && Array.isArray(factors) && factors.length > 0) {
|
||||
const factorLabels = factors.map(factor => {
|
||||
const factorLabel = t(`reasoning:factors.${factor.toLowerCase()}`);
|
||||
return factorLabel === `reasoning:factors.${factor.toLowerCase()}` ? factor : factorLabel;
|
||||
}).join(', ');
|
||||
metadata.push(`Factores: ${factorLabels}`);
|
||||
}
|
||||
|
||||
// Add confidence score
|
||||
if (confidence_score) {
|
||||
metadata.push(`Confianza: ${confidence_score}%`);
|
||||
}
|
||||
|
||||
// Add variance information
|
||||
if (variance) {
|
||||
metadata.push(`Varianza: ${variance}%`);
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.priority === ProductionPriority.URGENT) {
|
||||
metadata.push('⚡ Orden urgente');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useRef, useState, ReactNod
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useCurrentTenant } from '../stores/tenant.store';
|
||||
import { showToast } from '../utils/toast';
|
||||
import i18n from '../i18n';
|
||||
|
||||
interface SSEEvent {
|
||||
type: string;
|
||||
@@ -151,14 +152,41 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
toastType = 'info';
|
||||
}
|
||||
|
||||
// Show toast with enriched data
|
||||
const title = data.title || 'Alerta';
|
||||
// Translate title and message using i18n keys
|
||||
let title = 'Alerta';
|
||||
let message = 'Nueva alerta';
|
||||
|
||||
if (data.i18n?.title_key) {
|
||||
// Extract namespace from key (e.g., "alerts.critical_stock_shortage.title" -> namespace: "alerts", key: "critical_stock_shortage.title")
|
||||
const titleParts = data.i18n.title_key.split('.');
|
||||
const titleNamespace = titleParts[0];
|
||||
const titleKey = titleParts.slice(1).join('.');
|
||||
|
||||
title = String(i18n.t(titleKey, {
|
||||
ns: titleNamespace,
|
||||
...data.i18n.title_params,
|
||||
defaultValue: data.i18n.title_key
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.i18n?.message_key) {
|
||||
// Extract namespace from key (e.g., "alerts.critical_stock_shortage.message_generic" -> namespace: "alerts", key: "critical_stock_shortage.message_generic")
|
||||
const messageParts = data.i18n.message_key.split('.');
|
||||
const messageNamespace = messageParts[0];
|
||||
const messageKey = messageParts.slice(1).join('.');
|
||||
|
||||
message = String(i18n.t(messageKey, {
|
||||
ns: messageNamespace,
|
||||
...data.i18n.message_params,
|
||||
defaultValue: data.i18n.message_key
|
||||
}));
|
||||
}
|
||||
|
||||
const duration = data.priority_level === 'critical' ? 0 : 5000;
|
||||
|
||||
// Add financial impact to message if available
|
||||
let message = data.message;
|
||||
if (data.business_impact?.financial_impact_eur) {
|
||||
message = `${data.message} • €${data.business_impact.financial_impact_eur} en riesgo`;
|
||||
message = `${message} • €${data.business_impact.financial_impact_eur} en riesgo`;
|
||||
}
|
||||
|
||||
showToast[toastType](message, { title, duration });
|
||||
@@ -176,6 +204,209 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle notification events (from various services)
|
||||
eventSource.addEventListener('notification', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const sseEvent: SSEEvent = {
|
||||
type: 'notification',
|
||||
data,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLastEvent(sseEvent);
|
||||
|
||||
// Determine toast type based on notification priority or type
|
||||
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
|
||||
|
||||
// Use type_class if available from the new event architecture
|
||||
if (data.type_class) {
|
||||
if (data.type_class === 'success' || data.type_class === 'completed') {
|
||||
toastType = 'success';
|
||||
} else if (data.type_class === 'error') {
|
||||
toastType = 'error';
|
||||
} else if (data.type_class === 'warning') {
|
||||
toastType = 'warning';
|
||||
} else if (data.type_class === 'info') {
|
||||
toastType = 'info';
|
||||
}
|
||||
} else {
|
||||
// Fallback to priority_level for legacy compatibility
|
||||
if (data.priority_level === 'critical') {
|
||||
toastType = 'error';
|
||||
} else if (data.priority_level === 'important') {
|
||||
toastType = 'warning';
|
||||
} else if (data.priority_level === 'standard') {
|
||||
toastType = 'info';
|
||||
}
|
||||
}
|
||||
|
||||
// Translate title and message using i18n keys
|
||||
let title = 'Notificación';
|
||||
let message = 'Nueva notificación recibida';
|
||||
|
||||
if (data.i18n?.title_key) {
|
||||
// Extract namespace from key
|
||||
const titleParts = data.i18n.title_key.split('.');
|
||||
const titleNamespace = titleParts[0];
|
||||
const titleKey = titleParts.slice(1).join('.');
|
||||
|
||||
title = String(i18n.t(titleKey, {
|
||||
ns: titleNamespace,
|
||||
...data.i18n.title_params,
|
||||
defaultValue: data.i18n.title_key
|
||||
}));
|
||||
} else if (data.title || data.subject) {
|
||||
// Fallback to legacy fields if i18n not available
|
||||
title = data.title || data.subject;
|
||||
}
|
||||
|
||||
if (data.i18n?.message_key) {
|
||||
// Extract namespace from key
|
||||
const messageParts = data.i18n.message_key.split('.');
|
||||
const messageNamespace = messageParts[0];
|
||||
const messageKey = messageParts.slice(1).join('.');
|
||||
|
||||
message = String(i18n.t(messageKey, {
|
||||
ns: messageNamespace,
|
||||
...data.i18n.message_params,
|
||||
defaultValue: data.i18n.message_key
|
||||
}));
|
||||
} else if (data.message || data.content || data.description) {
|
||||
// Fallback to legacy fields if i18n not available
|
||||
message = data.message || data.content || data.description;
|
||||
}
|
||||
|
||||
// Add entity context to message if available
|
||||
if (data.entity_links && Object.keys(data.entity_links).length > 0) {
|
||||
const entityInfo = Object.entries(data.entity_links)
|
||||
.map(([type, id]) => `${type}: ${id}`)
|
||||
.join(', ');
|
||||
message = `${message} (${entityInfo})`;
|
||||
}
|
||||
|
||||
// Add state change information if available
|
||||
if (data.old_state && data.new_state) {
|
||||
message = `${message} - ${data.old_state} → ${data.new_state}`;
|
||||
}
|
||||
|
||||
const duration = data.priority_level === 'critical' ? 0 : 5000;
|
||||
|
||||
showToast[toastType](message, { title, duration });
|
||||
|
||||
// Trigger listeners with notification data
|
||||
// Wrap in queueMicrotask to prevent setState during render warnings
|
||||
const listeners = eventListenersRef.current.get('notification');
|
||||
if (listeners) {
|
||||
listeners.forEach(callback => {
|
||||
queueMicrotask(() => callback(data));
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing notification event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle recommendation events (AI-driven insights)
|
||||
eventSource.addEventListener('recommendation', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const sseEvent: SSEEvent = {
|
||||
type: 'recommendation',
|
||||
data,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLastEvent(sseEvent);
|
||||
|
||||
// Recommendations are typically positive insights
|
||||
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
|
||||
|
||||
// Use type_class if available from the new event architecture
|
||||
if (data.type_class) {
|
||||
if (data.type_class === 'opportunity' || data.type_class === 'insight') {
|
||||
toastType = 'success';
|
||||
} else if (data.type_class === 'error') {
|
||||
toastType = 'error';
|
||||
} else if (data.type_class === 'warning') {
|
||||
toastType = 'warning';
|
||||
} else if (data.type_class === 'info') {
|
||||
toastType = 'info';
|
||||
}
|
||||
} else {
|
||||
// Fallback to priority_level for legacy compatibility
|
||||
if (data.priority_level === 'critical') {
|
||||
toastType = 'error';
|
||||
} else if (data.priority_level === 'important') {
|
||||
toastType = 'warning';
|
||||
} else {
|
||||
toastType = 'info';
|
||||
}
|
||||
}
|
||||
|
||||
// Translate title and message using i18n keys
|
||||
let title = 'Recomendación';
|
||||
let message = 'Nueva recomendación del sistema AI';
|
||||
|
||||
if (data.i18n?.title_key) {
|
||||
// Extract namespace from key
|
||||
const titleParts = data.i18n.title_key.split('.');
|
||||
const titleNamespace = titleParts[0];
|
||||
const titleKey = titleParts.slice(1).join('.');
|
||||
|
||||
title = String(i18n.t(titleKey, {
|
||||
ns: titleNamespace,
|
||||
...data.i18n.title_params,
|
||||
defaultValue: data.i18n.title_key
|
||||
}));
|
||||
} else if (data.title) {
|
||||
// Fallback to legacy field if i18n not available
|
||||
title = data.title;
|
||||
}
|
||||
|
||||
if (data.i18n?.message_key) {
|
||||
// Extract namespace from key
|
||||
const messageParts = data.i18n.message_key.split('.');
|
||||
const messageNamespace = messageParts[0];
|
||||
const messageKey = messageParts.slice(1).join('.');
|
||||
|
||||
message = String(i18n.t(messageKey, {
|
||||
ns: messageNamespace,
|
||||
...data.i18n.message_params,
|
||||
defaultValue: data.i18n.message_key
|
||||
}));
|
||||
} else if (data.message) {
|
||||
// Fallback to legacy field if i18n not available
|
||||
message = data.message;
|
||||
}
|
||||
|
||||
// Add estimated impact if available
|
||||
if (data.estimated_impact) {
|
||||
const impact = data.estimated_impact;
|
||||
if (impact.savings_eur) {
|
||||
message = `${message} • €${impact.savings_eur} de ahorro estimado`;
|
||||
} else if (impact.risk_reduction_percent) {
|
||||
message = `${message} • ${impact.risk_reduction_percent}% reducción de riesgo`;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = 5000; // Recommendations are typically informational
|
||||
|
||||
showToast[toastType](message, { title, duration });
|
||||
|
||||
// Trigger listeners with recommendation data
|
||||
// Wrap in queueMicrotask to prevent setState during render warnings
|
||||
const listeners = eventListenersRef.current.get('recommendation');
|
||||
if (listeners) {
|
||||
listeners.forEach(callback => {
|
||||
queueMicrotask(() => callback(data));
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing recommendation event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
setIsConnected(false);
|
||||
|
||||
@@ -119,7 +119,11 @@
|
||||
"now": "Now",
|
||||
"recently": "Recently",
|
||||
"soon": "Soon",
|
||||
"later": "Later"
|
||||
"later": "Later",
|
||||
"just_now": "Just now",
|
||||
"minutes_ago": "{count, plural, one {# minute ago} other {# minutes ago}}",
|
||||
"hours_ago": "{count, plural, one {# hour ago} other {# hours ago}}",
|
||||
"days_ago": "{count, plural, one {# day ago} other {# days ago}}"
|
||||
},
|
||||
"units": {
|
||||
"kg": "kg",
|
||||
|
||||
@@ -409,6 +409,24 @@
|
||||
"failed": "Failed",
|
||||
"distribution_routes": "Distribution Routes"
|
||||
},
|
||||
"ai_insights": {
|
||||
"title": "AI Insights",
|
||||
"subtitle": "Strategic recommendations from your AI assistant",
|
||||
"view_all": "View All Insights",
|
||||
"no_insights": "No AI insights available yet",
|
||||
"impact_high": "High Impact",
|
||||
"impact_medium": "Medium Impact",
|
||||
"impact_low": "Low Impact",
|
||||
"savings": "potential savings",
|
||||
"reduction": "reduction potential",
|
||||
"types": {
|
||||
"cost_optimization": "Cost Optimization",
|
||||
"waste_reduction": "Waste Reduction",
|
||||
"safety_stock": "Safety Stock",
|
||||
"demand_forecast": "Demand Forecast",
|
||||
"risk_alert": "Risk Alert"
|
||||
}
|
||||
},
|
||||
"new_dashboard": {
|
||||
"system_status": {
|
||||
"title": "System Status",
|
||||
@@ -476,7 +494,49 @@
|
||||
"ai_reasoning": "AI scheduled this batch because:",
|
||||
"reasoning": {
|
||||
"forecast_demand": "Predicted demand of {demand} units for {product}",
|
||||
"forecast_demand_enhanced": "Predicted demand of {demand} units for {product} (+{variance}% vs historical)",
|
||||
"customer_order": "Customer order from {customer}"
|
||||
},
|
||||
"weather_forecast": "Weather Forecast",
|
||||
"weather_conditions": {
|
||||
"sunny": "Sunny, {temp}°C, {humidity}% humidity",
|
||||
"rainy": "Rainy, {temp}°C, {humidity}% humidity",
|
||||
"cold": "Cold, {temp}°C, {humidity}% humidity",
|
||||
"hot": "Hot, {temp}°C, {humidity}% humidity"
|
||||
},
|
||||
"demand_impact": "Demand Impact",
|
||||
"factors_title": "Prediction Factors",
|
||||
"factors": {
|
||||
"historical_pattern": "Historical Pattern",
|
||||
"weather_sunny": "Sunny Weather",
|
||||
"weather_rainy": "Rainy Weather",
|
||||
"weather_cold": "Cold Weather",
|
||||
"weather_hot": "Hot Weather",
|
||||
"weekend_boost": "Weekend Demand",
|
||||
"inventory_level": "Inventory Level",
|
||||
"seasonal_trend": "Seasonal Trend",
|
||||
"general": "Other Factor"
|
||||
},
|
||||
"confidence": "Confidence: {confidence}%",
|
||||
"variance": "Variance: +{variance}%",
|
||||
"historical_avg": "Hist. avg: {avg} units",
|
||||
"alerts_section": "Production Alerts",
|
||||
"alerts": {
|
||||
"equipment_maintenance": "Equipment Maintenance Required",
|
||||
"production_delay": "Production Delay",
|
||||
"batch_delayed": "Batch Start Delayed",
|
||||
"generic": "Production Alert",
|
||||
"active": "Active",
|
||||
"affected_orders": "{count, plural, one {# order} other {# orders}} affected",
|
||||
"delay_hours": "{hours}h delay",
|
||||
"financial_impact": "€{amount} impact",
|
||||
"urgent_in": "Urgent in {hours}h"
|
||||
},
|
||||
"priority": {
|
||||
"critical": "Critical",
|
||||
"important": "Important",
|
||||
"standard": "Standard",
|
||||
"info": "Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"productionBatch": {
|
||||
"forecast_demand": "Scheduled based on forecast: {predicted_demand} {product_name} needed (current stock: {current_stock}). Confidence: {confidence_score}%.",
|
||||
"forecast_demand_enhanced": "Scheduled based on enhanced forecast: {predicted_demand} {product_name} needed ({variance}% variance from historical average). Confidence: {confidence_score}%.",
|
||||
"customer_order": "Customer order for {customer_name}: {order_quantity} {product_name} (Order #{order_number}) - delivery {delivery_date}.",
|
||||
"stock_replenishment": "Stock replenishment for {product_name} - current level below minimum.",
|
||||
"seasonal_preparation": "Seasonal preparation batch for {product_name}.",
|
||||
@@ -177,5 +178,25 @@
|
||||
"inventory_replenishment": "Regular inventory replenishment",
|
||||
"production_schedule": "Scheduled production batch",
|
||||
"other": "Standard replenishment"
|
||||
},
|
||||
"factors": {
|
||||
"historical_pattern": "Historical Pattern",
|
||||
"weather_sunny": "Sunny Weather",
|
||||
"weather_rainy": "Rainy Weather",
|
||||
"weather_cold": "Cold Weather",
|
||||
"weather_hot": "Hot Weather",
|
||||
"weekend_boost": "Weekend Demand",
|
||||
"inventory_level": "Inventory Level",
|
||||
"seasonal_trend": "Seasonal Trend",
|
||||
"general": "Other Factor",
|
||||
"weather_impact_sunny": "Sunny Weather Impact",
|
||||
"seasonal_trend_adjustment": "Seasonal Trend Adjustment",
|
||||
"historical_sales_pattern": "Historical Sales Pattern",
|
||||
"current_inventory_trigger": "Current Inventory Trigger"
|
||||
},
|
||||
"dashboard": {
|
||||
"factors_title": "Key Factors Influencing This Decision",
|
||||
"confidence": "Confidence: {confidence}%",
|
||||
"variance": "Variance: {variance}% from historical average"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,11 @@
|
||||
"now": "Ahora",
|
||||
"recently": "Recientemente",
|
||||
"soon": "Pronto",
|
||||
"later": "Más tarde"
|
||||
"later": "Más tarde",
|
||||
"just_now": "Ahora mismo",
|
||||
"minutes_ago": "{count, plural, one {hace # minuto} other {hace # minutos}}",
|
||||
"hours_ago": "{count, plural, one {hace # hora} other {hace # horas}}",
|
||||
"days_ago": "{count, plural, one {hace # día} other {hace # días}}"
|
||||
},
|
||||
"units": {
|
||||
"kg": "kg",
|
||||
|
||||
@@ -458,6 +458,24 @@
|
||||
"failed": "Fallida",
|
||||
"distribution_routes": "Rutas de Distribución"
|
||||
},
|
||||
"ai_insights": {
|
||||
"title": "Insights de IA",
|
||||
"subtitle": "Recomendaciones estratégicas de tu asistente de IA",
|
||||
"view_all": "Ver Todos los Insights",
|
||||
"no_insights": "Aún no hay insights de IA disponibles",
|
||||
"impact_high": "Alto Impacto",
|
||||
"impact_medium": "Impacto Medio",
|
||||
"impact_low": "Bajo Impacto",
|
||||
"savings": "ahorro potencial",
|
||||
"reduction": "potencial de reducción",
|
||||
"types": {
|
||||
"cost_optimization": "Optimización de Costos",
|
||||
"waste_reduction": "Reducción de Desperdicio",
|
||||
"safety_stock": "Stock de Seguridad",
|
||||
"demand_forecast": "Pronóstico de Demanda",
|
||||
"risk_alert": "Alerta de Riesgo"
|
||||
}
|
||||
},
|
||||
"new_dashboard": {
|
||||
"system_status": {
|
||||
"title": "Estado del Sistema",
|
||||
@@ -525,7 +543,49 @@
|
||||
"ai_reasoning": "IA programó este lote porque:",
|
||||
"reasoning": {
|
||||
"forecast_demand": "Demanda prevista de {demand} unidades para {product}",
|
||||
"forecast_demand_enhanced": "Demanda prevista de {demand} unidades para {product} (+{variance}% vs histórico)",
|
||||
"customer_order": "Pedido del cliente {customer}"
|
||||
},
|
||||
"weather_forecast": "Previsión Meteorológica",
|
||||
"weather_conditions": {
|
||||
"sunny": "Soleado, {temp}°C, {humidity}% humedad",
|
||||
"rainy": "Lluvioso, {temp}°C, {humidity}% humedad",
|
||||
"cold": "Frío, {temp}°C, {humidity}% humedad",
|
||||
"hot": "Caluroso, {temp}°C, {humidity}% humedad"
|
||||
},
|
||||
"demand_impact": "Impacto en Demanda",
|
||||
"factors_title": "Factores de Predicción",
|
||||
"factors": {
|
||||
"historical_pattern": "Patrón Histórico",
|
||||
"weather_sunny": "Tiempo Soleado",
|
||||
"weather_rainy": "Tiempo Lluvioso",
|
||||
"weather_cold": "Tiempo Frío",
|
||||
"weather_hot": "Tiempo Caluroso",
|
||||
"weekend_boost": "Demanda de Fin de Semana",
|
||||
"inventory_level": "Nivel de Inventario",
|
||||
"seasonal_trend": "Tendencia Estacional",
|
||||
"general": "Otro Factor"
|
||||
},
|
||||
"confidence": "Confianza: {confidence}%",
|
||||
"variance": "Variación: +{variance}%",
|
||||
"historical_avg": "Media hist.: {avg} unidades",
|
||||
"alerts_section": "Alertas de Producción",
|
||||
"alerts": {
|
||||
"equipment_maintenance": "Mantenimiento de Equipo Requerido",
|
||||
"production_delay": "Retraso en Producción",
|
||||
"batch_delayed": "Lote con Inicio Retrasado",
|
||||
"generic": "Alerta de Producción",
|
||||
"active": "Activo",
|
||||
"affected_orders": "{count, plural, one {# pedido} other {# pedidos}} afectados",
|
||||
"delay_hours": "{hours}h de retraso",
|
||||
"financial_impact": "€{amount} de impacto",
|
||||
"urgent_in": "Urgente en {hours}h"
|
||||
},
|
||||
"priority": {
|
||||
"critical": "Crítico",
|
||||
"important": "Importante",
|
||||
"standard": "Estándar",
|
||||
"info": "Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"productionBatch": {
|
||||
"forecast_demand": "Programado según pronóstico: {predicted_demand} {product_name} necesarios (stock actual: {current_stock}). Confianza: {confidence_score}%.",
|
||||
"forecast_demand_enhanced": "Programado según pronóstico mejorado: {predicted_demand} {product_name} necesarios ({variance}% variación del promedio histórico). Confianza: {confidence_score}%.",
|
||||
"customer_order": "Pedido de cliente para {customer_name}: {order_quantity} {product_name} (Pedido #{order_number}) - entrega {delivery_date}.",
|
||||
"stock_replenishment": "Reposición de stock para {product_name} - nivel actual por debajo del mínimo.",
|
||||
"seasonal_preparation": "Lote de preparación estacional para {product_name}.",
|
||||
@@ -177,5 +178,25 @@
|
||||
"inventory_replenishment": "Reposición regular de inventario",
|
||||
"production_schedule": "Lote de producción programado",
|
||||
"other": "Reposición estándar"
|
||||
},
|
||||
"factors": {
|
||||
"historical_pattern": "Patrón Histórico",
|
||||
"weather_sunny": "Tiempo Soleado",
|
||||
"weather_rainy": "Tiempo Lluvioso",
|
||||
"weather_cold": "Tiempo Frío",
|
||||
"weather_hot": "Tiempo Caluroso",
|
||||
"weekend_boost": "Demanda de Fin de Semana",
|
||||
"inventory_level": "Nivel de Inventario",
|
||||
"seasonal_trend": "Tendencia Estacional",
|
||||
"general": "Otro Factor",
|
||||
"weather_impact_sunny": "Impacto del Tiempo Soleado",
|
||||
"seasonal_trend_adjustment": "Ajuste de Tendencia Estacional",
|
||||
"historical_sales_pattern": "Patrón de Ventas Histórico",
|
||||
"current_inventory_trigger": "Activador de Inventario Actual"
|
||||
},
|
||||
"dashboard": {
|
||||
"factors_title": "Factores Clave que Influencian esta Decisión",
|
||||
"confidence": "Confianza: {confidence}%",
|
||||
"variance": "Variación: {variance}% del promedio histórico"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,11 @@
|
||||
"now": "Orain",
|
||||
"recently": "Duela gutxi",
|
||||
"soon": "Laster",
|
||||
"later": "Geroago"
|
||||
"later": "Geroago",
|
||||
"just_now": "Orain bertan",
|
||||
"minutes_ago": "{count, plural, one {duela # minutu} other {duela # minutu}}",
|
||||
"hours_ago": "{count, plural, one {duela # ordu} other {duela # ordu}}",
|
||||
"days_ago": "{count, plural, one {duela # egun} other {duela # egun}}"
|
||||
},
|
||||
"units": {
|
||||
"kg": "kg",
|
||||
|
||||
@@ -122,10 +122,6 @@
|
||||
"acknowledged": "Onartu",
|
||||
"resolved": "Ebatzi"
|
||||
},
|
||||
"types": {
|
||||
"alert": "Alerta",
|
||||
"recommendation": "Gomendioa"
|
||||
},
|
||||
"recommended_actions": "Gomendatutako Ekintzak",
|
||||
"additional_details": "Xehetasun Gehigarriak",
|
||||
"mark_as_read": "Irakurritako gisa markatu",
|
||||
@@ -463,7 +459,49 @@
|
||||
"ai_reasoning": "IAk lote hau programatu zuen zeren:",
|
||||
"reasoning": {
|
||||
"forecast_demand": "{product}-rentzat {demand} unitateko eskaria aurreikusita",
|
||||
"forecast_demand_enhanced": "{product}-rentzat {demand} unitateko eskaria aurreikusita (+{variance}% historikoarekin alderatuta)",
|
||||
"customer_order": "{customer} bezeroaren eskaera"
|
||||
},
|
||||
"weather_forecast": "Eguraldi Iragarpena",
|
||||
"weather_conditions": {
|
||||
"sunny": "Eguzkitsua, {temp}°C, %{humidity} hezetasuna",
|
||||
"rainy": "Euritsua, {temp}°C, %{humidity} hezetasuna",
|
||||
"cold": "Hotza, {temp}°C, %{humidity} hezetasuna",
|
||||
"hot": "Beroa, {temp}°C, %{humidity} hezetasuna"
|
||||
},
|
||||
"demand_impact": "Eskarian Eragina",
|
||||
"factors_title": "Aurreikuspen Faktoreak",
|
||||
"factors": {
|
||||
"historical_pattern": "Eredu Historikoa",
|
||||
"weather_sunny": "Eguraldi Eguzkitsua",
|
||||
"weather_rainy": "Eguraldi Euritsua",
|
||||
"weather_cold": "Eguraldi Hotza",
|
||||
"weather_hot": "Eguraldi Beroa",
|
||||
"weekend_boost": "Asteburuaren Eskaria",
|
||||
"inventory_level": "Inbentario Maila",
|
||||
"seasonal_trend": "Sasoi Joera",
|
||||
"general": "Beste Faktore bat"
|
||||
},
|
||||
"confidence": "Konfiantza: %{confidence}",
|
||||
"variance": "Aldakuntza: +%{variance}",
|
||||
"historical_avg": "Batez bestekoa: {avg} unitate",
|
||||
"alerts_section": "Ekoizpen Alertak",
|
||||
"alerts": {
|
||||
"equipment_maintenance": "Ekipoen Mantentze-Lanak Behar",
|
||||
"production_delay": "Ekoizpenaren Atzerapena",
|
||||
"batch_delayed": "Lotearen Hasiera Atzeratuta",
|
||||
"generic": "Ekoizpen Alerta",
|
||||
"active": "Aktiboa",
|
||||
"affected_orders": "{count, plural, one {# eskaera} other {# eskaera}} kaltetuak",
|
||||
"delay_hours": "{hours}h atzerapena",
|
||||
"financial_impact": "€{amount} eragina",
|
||||
"urgent_in": "Presazkoa {hours}h-tan"
|
||||
},
|
||||
"priority": {
|
||||
"critical": "Kritikoa",
|
||||
"important": "Garrantzitsua",
|
||||
"standard": "Estandarra",
|
||||
"info": "Informazioa"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"orchestration": {
|
||||
"daily_summary": "{purchase_orders_count, plural, =0 {} =1 {1 erosketa agindu sortu} other {{purchase_orders_count} erosketa agindu sortu}}{purchase_orders_count, plural, =0 {} other { eta }}{production_batches_count, plural, =0 {ekoizpen loterik ez} =1 {1 ekoizpen lote programatu} other {{production_batches_count} ekoizpen lote programatu}}. {critical_items_count, plural, =0 {Guztia stockean.} =1 {Artikulu kritiko 1 arreta behar du} other {{critical_items_count} artikulu kritiko arreta behar dute}}{total_financial_impact_eur, select, 0 {} other { (€{total_financial_impact_eur} arriskuan)}}{min_depletion_hours, select, 0 {} other { - {min_depletion_hours}h stock amaitu arte}}."
|
||||
},
|
||||
"purchaseOrder": {
|
||||
"low_stock_detection": "{supplier_name}-rentzat stock baxua. {product_names_joined}-ren egungo stocka {days_until_stockout} egunetan amaituko da.",
|
||||
"low_stock_detection_detailed": "{critical_product_count, plural, =1 {{critical_products_0} {min_depletion_hours} ordutan amaituko da} other {{critical_product_count} produktu kritiko urri}}. {supplier_name}-ren {supplier_lead_time_days} eguneko entregarekin, {order_urgency, select, critical {BEREHALA} urgent {GAUR} important {laster} other {orain}} eskatu behar dugu {affected_batches_count, plural, =0 {ekoizpen atzerapenak} =1 {{affected_batches_0} lotearen etetea} other {{affected_batches_count} loteen etetea}} saihesteko{potential_loss_eur, select, 0 {} other { (€{potential_loss_eur} arriskuan)}}.",
|
||||
@@ -11,6 +14,7 @@
|
||||
},
|
||||
"productionBatch": {
|
||||
"forecast_demand": "Aurreikuspenen arabera programatua: {predicted_demand} {product_name} behar dira (egungo stocka: {current_stock}). Konfiantza: {confidence_score}%.",
|
||||
"forecast_demand_enhanced": "Aurreikuspen hobetuaren arabera programatua: {predicted_demand} {product_name} behar dira ({variance}% aldaketa batez besteko historikoarekiko). Konfiantza: {confidence_score}%.",
|
||||
"customer_order": "{customer_name}-rentzat bezeroaren eskaera: {order_quantity} {product_name} (Eskaera #{order_number}) - entrega {delivery_date}.",
|
||||
"stock_replenishment": "{product_name}-rentzat stockaren birjartzea - egungo maila minimoa baino txikiagoa.",
|
||||
"seasonal_preparation": "{product_name}-rentzat denboraldiko prestaketa lotea.",
|
||||
@@ -174,5 +178,25 @@
|
||||
"inventory_replenishment": "Inbentario berritze erregularra",
|
||||
"production_schedule": "Ekoizpen sorta programatua",
|
||||
"other": "Berritze estandarra"
|
||||
},
|
||||
"factors": {
|
||||
"historical_pattern": "Eredu Historikoa",
|
||||
"weather_sunny": "Eguraldi Eguzkitsua",
|
||||
"weather_rainy": "Eguraldi Euritsua",
|
||||
"weather_cold": "Eguraldi Hotza",
|
||||
"weather_hot": "Eguraldi Beroa",
|
||||
"weekend_boost": "Asteburuaren Eskaria",
|
||||
"inventory_level": "Inbentario Maila",
|
||||
"seasonal_trend": "Sasoi Joera",
|
||||
"general": "Beste Faktore bat",
|
||||
"weather_impact_sunny": "Eguraldi Eguzkitsuaren Eragina",
|
||||
"seasonal_trend_adjustment": "Sasoi Joeraren Doikuntza",
|
||||
"historical_sales_pattern": "Salmenta Eredu Historikoa",
|
||||
"current_inventory_trigger": "Egungo Inbentario Aktibatzailea"
|
||||
},
|
||||
"dashboard": {
|
||||
"factors_title": "Erabaki hau eragiten duten faktore gakoak",
|
||||
"confidence": "Konfiantza: {confidence}%",
|
||||
"variance": "Aldaketa: % {variance} batez besteko historikoarekiko"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
PendingPurchasesBlock,
|
||||
PendingDeliveriesBlock,
|
||||
ProductionStatusBlock,
|
||||
AIInsightsBlock,
|
||||
} from '../../components/dashboard/blocks';
|
||||
import { UnifiedPurchaseOrderModal } from '../../components/domain/procurement/UnifiedPurchaseOrderModal';
|
||||
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
|
||||
@@ -50,7 +51,7 @@ import { useSubscription } from '../../api/hooks/subscription';
|
||||
import { SUBSCRIPTION_TIERS } from '../../api/types/subscription';
|
||||
|
||||
// Rename the existing component to BakeryDashboard
|
||||
export function BakeryDashboard() {
|
||||
export function BakeryDashboard({ plan }: { plan?: string }) {
|
||||
const { t } = useTranslation(['dashboard', 'common', 'alerts']);
|
||||
const { currentTenant } = useTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
@@ -415,10 +416,25 @@ export function BakeryDashboard() {
|
||||
lateToStartBatches={dashboardData?.lateToStartBatches || []}
|
||||
runningBatches={dashboardData?.runningBatches || []}
|
||||
pendingBatches={dashboardData?.pendingBatches || []}
|
||||
alerts={dashboardData?.alerts || []}
|
||||
loading={dashboardLoading}
|
||||
onStartBatch={handleStartBatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* BLOCK 5: AI Insights (Professional/Enterprise only) */}
|
||||
{(plan === SUBSCRIPTION_TIERS.PROFESSIONAL || plan === SUBSCRIPTION_TIERS.ENTERPRISE) && (
|
||||
<div data-tour="ai-insights">
|
||||
<AIInsightsBlock
|
||||
insights={dashboardData?.aiInsights || []}
|
||||
loading={dashboardLoading}
|
||||
onViewAll={() => {
|
||||
// Navigate to AI Insights page
|
||||
window.location.href = '/app/analytics/ai-insights';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -480,7 +496,7 @@ export function DashboardPage() {
|
||||
return <EnterpriseDashboardPage tenantId={tenantId} />;
|
||||
}
|
||||
|
||||
return <BakeryDashboard />;
|
||||
return <BakeryDashboard plan={plan} />;
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -193,7 +193,7 @@ const MaquinariaPage: React.FC = () => {
|
||||
maintenance: { color: getStatusColor('info'), text: t('equipment_status.maintenance'), icon: Wrench },
|
||||
down: { color: getStatusColor('error'), text: t('equipment_status.down'), icon: AlertTriangle }
|
||||
};
|
||||
return configs[status];
|
||||
return configs[status] || { color: getStatusColor('other'), text: status, icon: Settings };
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: Equipment['type']) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play } from 'lucide-react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play, Info } from 'lucide-react';
|
||||
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
@@ -666,6 +666,58 @@ const ProductionPage: React.FC = () => {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Detalles del Razonamiento',
|
||||
icon: Info,
|
||||
fields: [
|
||||
{
|
||||
label: 'Causa Principal',
|
||||
value: selectedBatch.reasoning_data?.trigger_type
|
||||
? t(`reasoning:triggers.${selectedBatch.reasoning_data.trigger_type.toLowerCase()}`)
|
||||
: 'No especificado',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Descripción del Razonamiento',
|
||||
value: selectedBatch.reasoning_data?.trigger_description || 'No especificado',
|
||||
type: 'textarea',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Factores Clave',
|
||||
value: selectedBatch.reasoning_data?.factors && Array.isArray(selectedBatch.reasoning_data.factors)
|
||||
? selectedBatch.reasoning_data.factors.map(factor =>
|
||||
t(`reasoning:factors.${factor.toLowerCase()}`) || factor
|
||||
).join(', ')
|
||||
: 'No especificados',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Consecuencias Potenciales',
|
||||
value: selectedBatch.reasoning_data?.consequence || 'No especificado',
|
||||
type: 'textarea',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Nivel de Confianza',
|
||||
value: selectedBatch.reasoning_data?.confidence_score
|
||||
? `${selectedBatch.reasoning_data.confidence_score}%`
|
||||
: 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Variación Histórica',
|
||||
value: selectedBatch.reasoning_data?.variance
|
||||
? `${selectedBatch.reasoning_data.variance}%`
|
||||
: 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Detalles de la Predicción',
|
||||
value: selectedBatch.reasoning_data?.prediction_details || 'No especificado',
|
||||
type: 'textarea',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Calidad y Costos',
|
||||
icon: CheckCircle,
|
||||
@@ -733,6 +785,10 @@ const ProductionPage: React.FC = () => {
|
||||
'Estado': 'status',
|
||||
'Prioridad': 'priority',
|
||||
'Personal Asignado': 'staff_assigned',
|
||||
// Reasoning section editable fields
|
||||
'Descripción del Razonamiento': 'reasoning_data.trigger_description',
|
||||
'Consecuencias Potenciales': 'reasoning_data.consequence',
|
||||
'Detalles de la Predicción': 'reasoning_data.prediction_details',
|
||||
// Schedule - most fields are read-only datetime
|
||||
// Quality and Costs
|
||||
'Notas de Producción': 'production_notes',
|
||||
@@ -744,6 +800,7 @@ const ProductionPage: React.FC = () => {
|
||||
['Producto', 'Número de Lote', 'Cantidad Planificada', 'Cantidad Producida', 'Estado', 'Prioridad', 'Personal Asignado', 'Equipos Utilizados'],
|
||||
['Inicio Planificado', 'Fin Planificado', 'Duración Planificada', 'Inicio Real', 'Fin Real', 'Duración Real'],
|
||||
[], // Process Stage Tracker section - no editable fields
|
||||
['Causa Principal', 'Descripción del Razonamiento', 'Factores Clave', 'Consecuencias Potenciales', 'Nivel de Confianza', 'Variación Histórica', 'Detalles de la Predicción'], // Reasoning section
|
||||
['Puntuación de Calidad', 'Rendimiento', 'Costo Estimado', 'Costo Real', 'Notas de Producción', 'Notas de Calidad']
|
||||
];
|
||||
|
||||
@@ -760,10 +817,22 @@ const ProductionPage: React.FC = () => {
|
||||
processedValue = parseFloat(value as string) || 0;
|
||||
}
|
||||
|
||||
setSelectedBatch({
|
||||
...selectedBatch,
|
||||
[propertyName]: processedValue
|
||||
});
|
||||
// Handle nested reasoning_data fields
|
||||
if (propertyName.startsWith('reasoning_data.')) {
|
||||
const nestedProperty = propertyName.split('.')[1];
|
||||
setSelectedBatch({
|
||||
...selectedBatch,
|
||||
reasoning_data: {
|
||||
...(selectedBatch.reasoning_data || {}),
|
||||
[nestedProperty]: processedValue
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setSelectedBatch({
|
||||
...selectedBatch,
|
||||
[propertyName]: processedValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -37,6 +37,11 @@ const success = (message: string, options?: ToastOptions): string => {
|
||||
return toast.success(fullMessage, {
|
||||
duration,
|
||||
id: options?.id,
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -55,6 +60,11 @@ const error = (message: string, options?: ToastOptions): string => {
|
||||
return toast.error(fullMessage, {
|
||||
duration,
|
||||
id: options?.id,
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,6 +84,11 @@ const warning = (message: string, options?: ToastOptions): string => {
|
||||
duration,
|
||||
id: options?.id,
|
||||
icon: '⚠️',
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -93,6 +108,11 @@ const info = (message: string, options?: ToastOptions): string => {
|
||||
duration,
|
||||
id: options?.id,
|
||||
icon: 'ℹ️',
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -111,6 +131,11 @@ const loading = (message: string, options?: ToastOptions): string => {
|
||||
return toast.loading(fullMessage, {
|
||||
duration,
|
||||
id: options?.id,
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: worker
|
||||
image: bakery/demo-session-service:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
image: demo-session-service:latest
|
||||
imagePullPolicy: Never
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-ai-models
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "25"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-ai-models
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-training-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for training-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-training-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for training-service to be ready..."
|
||||
until curl -f http://training-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "training-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "training-service is ready!"
|
||||
containers:
|
||||
- name: seed-ai-models
|
||||
image: bakery/training-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_ai_models.py"]
|
||||
env:
|
||||
- name: TRAINING_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: TRAINING_DATABASE_URL
|
||||
- name: TENANT_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: TENANT_DATABASE_URL
|
||||
- name: INVENTORY_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: INVENTORY_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,67 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-alerts
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "28" # After orchestration runs (27), as alerts reference recent data
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-alerts
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-alert-processor-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for alert-processor-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-alert-processor
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for alert-processor to be ready..."
|
||||
until curl -f http://alert-processor.bakery-ia.svc.cluster.local:8000/health > /dev/null 2>&1; do
|
||||
echo "alert-processor not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "alert-processor is ready!"
|
||||
containers:
|
||||
- name: seed-alerts
|
||||
image: bakery/alert-processor:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_alerts.py"]
|
||||
env:
|
||||
- name: ALERT_PROCESSOR_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ALERT_PROCESSOR_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ALERT_PROCESSOR_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,55 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-alerts-retail
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: retail
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "56" # After retail forecasts (55)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-alerts-retail
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-alert-processor
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for alert-processor to be ready..."
|
||||
until curl -f http://alert-processor.bakery-ia.svc.cluster.local:8000/health > /dev/null 2>&1; do
|
||||
echo "alert-processor not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "alert-processor is ready!"
|
||||
containers:
|
||||
- name: seed-alerts-retail
|
||||
image: bakery/alert-processor:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_alerts_retail.py"]
|
||||
env:
|
||||
- name: ALERT_PROCESSOR_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ALERT_PROCESSOR_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-customers
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "25" # After orders migration (20)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-customers
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-orders-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for orders-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-orders-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for orders-service to be ready..."
|
||||
until curl -f http://orders-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "orders-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "orders-service is ready!"
|
||||
containers:
|
||||
- name: seed-customers
|
||||
image: bakery/orders-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_customers.py"]
|
||||
env:
|
||||
- name: ORDERS_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ORDERS_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,55 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-customers-retail
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: retail
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "53" # After retail sales (52)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-customers-retail
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-orders-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for orders-service to be ready..."
|
||||
until curl -f http://orders-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "orders-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "orders-service is ready!"
|
||||
containers:
|
||||
- name: seed-customers-retail
|
||||
image: bakery/orders-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_customers_retail.py"]
|
||||
env:
|
||||
- name: ORDERS_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ORDERS_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,64 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-distribution-history
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: enterprise
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "57" # After all retail seeds (56) - CRITICAL for enterprise demo
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-distribution-history
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-distribution-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for distribution-service to be ready..."
|
||||
until curl -f http://distribution-service.bakery-ia.svc.cluster.local:8000/health > /dev/null 2>&1; do
|
||||
echo "distribution-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "distribution-service is ready!"
|
||||
- name: wait-for-all-retail-seeds
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 60 seconds for all retail seeds to complete..."
|
||||
echo "This ensures distribution history has all child data in place"
|
||||
sleep 60
|
||||
containers:
|
||||
- name: seed-distribution-history
|
||||
image: bakery/distribution-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_distribution_history.py"]
|
||||
env:
|
||||
- name: DISTRIBUTION_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: DISTRIBUTION_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-equipment
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "25" # After production migration (20)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-equipment
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-production-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for production-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-production-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for production-service to be ready..."
|
||||
until curl -f http://production-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "production-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "production-service is ready!"
|
||||
containers:
|
||||
- name: seed-equipment
|
||||
image: bakery/production-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_equipment.py"]
|
||||
env:
|
||||
- name: PRODUCTION_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: PRODUCTION_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-forecasts
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "40" # Last seed job
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-forecasts
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-forecasting-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for forecasting-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-forecasting-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for forecasting-service to be ready..."
|
||||
until curl -f http://forecasting-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "forecasting-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "forecasting-service is ready!"
|
||||
containers:
|
||||
- name: seed-forecasts
|
||||
image: bakery/forecasting-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_forecasts.py"]
|
||||
env:
|
||||
- name: FORECASTING_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: FORECASTING_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "200m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,55 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-forecasts-retail
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: retail
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "55" # After retail POS (54)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-forecasts-retail
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-forecasting-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for forecasting-service to be ready..."
|
||||
until curl -f http://forecasting-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "forecasting-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "forecasting-service is ready!"
|
||||
containers:
|
||||
- name: seed-forecasts-retail
|
||||
image: bakery/forecasting-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_forecasts_retail.py"]
|
||||
env:
|
||||
- name: FORECASTING_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: FORECASTING_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-inventory
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "15"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-inventory
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-inventory-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for inventory-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-inventory-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for inventory-service to be ready..."
|
||||
until curl -f http://inventory-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "inventory-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "inventory-service is ready!"
|
||||
containers:
|
||||
- name: seed-inventory
|
||||
image: bakery/inventory-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_inventory.py"]
|
||||
env:
|
||||
- name: INVENTORY_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: INVENTORY_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,63 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-inventory-retail
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: retail
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "50" # After parent inventory (15)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-inventory-retail
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-parent-inventory
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 45 seconds for parent inventory seed to complete..."
|
||||
sleep 45
|
||||
- name: wait-for-inventory-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for inventory-service to be ready..."
|
||||
until curl -f http://inventory-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "inventory-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "inventory-service is ready!"
|
||||
containers:
|
||||
- name: seed-inventory-retail
|
||||
image: bakery/inventory-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_inventory_retail.py"]
|
||||
env:
|
||||
- name: INVENTORY_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: INVENTORY_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,67 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-orchestration-runs
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "45" # After procurement plans (35)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-orchestration-runs
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-orchestrator-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "⏳ Waiting 30 seconds for orchestrator-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-orchestrator-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for orchestrator-service to be ready..."
|
||||
until curl -f http://orchestrator-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "orchestrator-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "orchestrator-service is ready!"
|
||||
containers:
|
||||
- name: seed-orchestration-runs
|
||||
image: bakery/orchestrator-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_orchestration_runs.py"]
|
||||
env:
|
||||
- name: ORCHESTRATOR_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ORCHESTRATOR_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ORCHESTRATOR_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "200m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,59 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-orchestrator
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "25" # After procurement plans (24)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-orchestrator
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-orchestrator-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for orchestrator-service to be ready..."
|
||||
until curl -f http://orchestrator-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "orchestrator-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "orchestrator-service is ready!"
|
||||
containers:
|
||||
- name: seed-orchestrator
|
||||
image: bakery/orchestrator-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_orchestration_runs.py"]
|
||||
env:
|
||||
- name: ORCHESTRATOR_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ORCHESTRATOR_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ORCHESTRATOR_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "200m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-orders
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "30" # After customers (25)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-orders
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-orders-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for orders-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-orders-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for orders-service to be ready..."
|
||||
until curl -f http://orders-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "orders-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "orders-service is ready!"
|
||||
containers:
|
||||
- name: seed-orders
|
||||
image: bakery/orders-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_orders.py"]
|
||||
env:
|
||||
- name: ORDERS_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: ORDERS_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "200m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-pos-configs
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "35" # After orders (30)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-pos-configs
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-pos-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for pos-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-pos-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for pos-service to be ready..."
|
||||
until curl -f http://pos-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "pos-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "pos-service is ready!"
|
||||
containers:
|
||||
- name: seed-pos-configs
|
||||
image: bakery/pos-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_pos_configs.py"]
|
||||
env:
|
||||
- name: POS_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: POS_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,55 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-pos-retail
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: retail
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "54" # After retail customers (53)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-pos-retail
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-pos-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for pos-service to be ready..."
|
||||
until curl -f http://pos-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "pos-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "pos-service is ready!"
|
||||
containers:
|
||||
- name: seed-pos-retail
|
||||
image: bakery/pos-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_pos_retail.py"]
|
||||
env:
|
||||
- name: POS_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: POS_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,67 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-procurement-plans
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "21" # After suppliers (20)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-procurement-plans
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-procurement-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for procurement-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-procurement-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for procurement-service to be ready..."
|
||||
until curl -f http://procurement-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "procurement-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "procurement-service is ready!"
|
||||
containers:
|
||||
- name: seed-procurement-plans
|
||||
image: bakery/procurement-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_procurement_plans.py"]
|
||||
env:
|
||||
- name: PROCUREMENT_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: PROCUREMENT_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: PROCUREMENT_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "200m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-production-batches
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "30" # After equipment (25) and other dependencies
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-production-batches
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-production-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for production-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-production-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for production-service to be ready..."
|
||||
until curl -f http://production-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "production-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "production-service is ready!"
|
||||
containers:
|
||||
- name: seed-production-batches
|
||||
image: bakery/production-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_batches.py"]
|
||||
env:
|
||||
- name: PRODUCTION_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: PRODUCTION_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,59 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-purchase-orders
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "22" # After procurement plans (21)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-purchase-orders
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-procurement-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for procurement-service to be ready..."
|
||||
until curl -f http://procurement-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "procurement-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "procurement-service is ready!"
|
||||
containers:
|
||||
- name: seed-purchase-orders
|
||||
image: bakery/procurement-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_purchase_orders.py"]
|
||||
env:
|
||||
- name: PROCUREMENT_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: PROCUREMENT_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: PROCUREMENT_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "200m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-quality-templates
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "22" # After production migration (20), before equipment (25)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-quality-templates
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-production-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for production-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-production-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for production-service to be ready..."
|
||||
until curl -f http://production-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "production-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "production-service is ready!"
|
||||
containers:
|
||||
- name: seed-quality-templates
|
||||
image: bakery/production-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_quality_templates.py"]
|
||||
env:
|
||||
- name: PRODUCTION_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: PRODUCTION_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,29 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: demo-seed-sa
|
||||
namespace: bakery-ia
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: demo-seed-role
|
||||
namespace: bakery-ia
|
||||
rules:
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: demo-seed-rolebinding
|
||||
namespace: bakery-ia
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: demo-seed-role
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: demo-seed-sa
|
||||
namespace: bakery-ia
|
||||
@@ -1,67 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-recipes
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "20"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-recipes
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-recipes-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for recipes-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-recipes-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for recipes-service to be ready..."
|
||||
until curl -f http://recipes-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "recipes-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "recipes-service is ready!"
|
||||
containers:
|
||||
- name: seed-recipes
|
||||
image: bakery/recipes-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_recipes.py"]
|
||||
env:
|
||||
- name: RECIPES_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: RECIPES_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: RECIPES_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,67 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-sales
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "25"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-sales
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-sales-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for sales-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-sales-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for sales-service to be ready..."
|
||||
until curl -f http://sales-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "sales-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "sales-service is ready!"
|
||||
containers:
|
||||
- name: seed-sales
|
||||
image: bakery/sales-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_sales.py"]
|
||||
env:
|
||||
- name: SALES_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: SALES_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: SALES_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,63 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-sales-retail
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: retail
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "52" # After retail stock (51)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-sales-retail
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-retail-stock
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for retail stock seed to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-sales-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for sales-service to be ready..."
|
||||
until curl -f http://sales-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "sales-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "sales-service is ready!"
|
||||
containers:
|
||||
- name: seed-sales-retail
|
||||
image: bakery/sales-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_sales_retail.py"]
|
||||
env:
|
||||
- name: SALES_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: SALES_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-stock
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "20"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-stock
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-inventory-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for inventory-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-inventory-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for inventory-service to be ready..."
|
||||
until curl -f http://inventory-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "inventory-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "inventory-service is ready!"
|
||||
containers:
|
||||
- name: seed-stock
|
||||
image: bakery/inventory-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_stock.py"]
|
||||
env:
|
||||
- name: INVENTORY_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: INVENTORY_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,51 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-stock-retail
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
tier: retail
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "51" # After retail inventory (50)
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-stock-retail
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-retail-inventory
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for retail inventory seed to complete..."
|
||||
sleep 30
|
||||
containers:
|
||||
- name: seed-stock-retail
|
||||
image: bakery/inventory-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_stock_retail.py"]
|
||||
env:
|
||||
- name: INVENTORY_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: INVENTORY_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,56 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-subscriptions
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "15"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-subscriptions
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-tenant-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for tenant-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-tenant-seed
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 15 seconds for demo-seed-tenants to complete..."
|
||||
sleep 15
|
||||
containers:
|
||||
- name: seed-subscriptions
|
||||
image: bakery/tenant-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_subscriptions.py"]
|
||||
env:
|
||||
- name: TENANT_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: TENANT_DATABASE_URL
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,67 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-suppliers
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "20"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-suppliers
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-suppliers-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for suppliers-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-suppliers-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for suppliers-service to be ready..."
|
||||
until curl -f http://suppliers-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "suppliers-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "suppliers-service is ready!"
|
||||
containers:
|
||||
- name: seed-suppliers
|
||||
image: bakery/suppliers-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_suppliers.py"]
|
||||
env:
|
||||
- name: SUPPLIERS_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: SUPPLIERS_DATABASE_URL
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: SUPPLIERS_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,52 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-tenant-members
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "15"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-tenant-members
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-tenant-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for tenant-service to be ready..."
|
||||
until curl -f http://tenant-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "tenant-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "tenant-service is ready!"
|
||||
containers:
|
||||
- name: seed-tenant-members
|
||||
image: bakery/tenant-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_tenant_members.py"]
|
||||
env:
|
||||
- name: TENANT_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: TENANT_DATABASE_URL
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,64 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-tenants
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "10"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-tenants
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-tenant-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for tenant-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-tenant-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for tenant-service to be ready..."
|
||||
until curl -f http://tenant-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "tenant-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "tenant-service is ready!"
|
||||
containers:
|
||||
- name: seed-tenants
|
||||
image: bakery/tenant-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_tenants.py"]
|
||||
env:
|
||||
- name: TENANT_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: TENANT_DATABASE_URL
|
||||
- name: AUTH_SERVICE_URL
|
||||
value: "http://auth-service:8000"
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -1,62 +0,0 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: demo-seed-users
|
||||
namespace: bakery-ia
|
||||
labels:
|
||||
app: demo-seed
|
||||
component: initialization
|
||||
annotations:
|
||||
"helm.sh/hook": post-install,post-upgrade
|
||||
"helm.sh/hook-weight": "5"
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 3600
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: demo-seed-users
|
||||
spec:
|
||||
initContainers:
|
||||
- name: wait-for-auth-migration
|
||||
image: busybox:1.36
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting 30 seconds for auth-migration to complete..."
|
||||
sleep 30
|
||||
- name: wait-for-auth-service
|
||||
image: curlimages/curl:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
echo "Waiting for auth-service to be ready..."
|
||||
until curl -f http://auth-service.bakery-ia.svc.cluster.local:8000/health/ready > /dev/null 2>&1; do
|
||||
echo "auth-service not ready yet, waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "auth-service is ready!"
|
||||
containers:
|
||||
- name: seed-users
|
||||
image: bakery/auth-service:latest
|
||||
command: ["python", "/app/scripts/demo/seed_demo_users.py"]
|
||||
env:
|
||||
- name: AUTH_DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: AUTH_DATABASE_URL
|
||||
- name: DEMO_MODE
|
||||
value: "production"
|
||||
- name: LOG_LEVEL
|
||||
value: "INFO"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
restartPolicy: OnFailure
|
||||
serviceAccountName: demo-seed-sa
|
||||
@@ -42,40 +42,6 @@ resources:
|
||||
- migrations/ai-insights-migration-job.yaml
|
||||
- migrations/distribution-migration-job.yaml
|
||||
|
||||
# Demo initialization jobs (in Helm hook weight order)
|
||||
- jobs/demo-seed-rbac.yaml
|
||||
- jobs/demo-seed-users-job.yaml
|
||||
- jobs/demo-seed-tenants-job.yaml
|
||||
- jobs/demo-seed-tenant-members-job.yaml
|
||||
- jobs/demo-seed-subscriptions-job.yaml
|
||||
- jobs/demo-seed-inventory-job.yaml
|
||||
- jobs/demo-seed-recipes-job.yaml
|
||||
- jobs/demo-seed-suppliers-job.yaml
|
||||
- jobs/demo-seed-purchase-orders-job.yaml
|
||||
- jobs/demo-seed-sales-job.yaml
|
||||
- jobs/demo-seed-ai-models-job.yaml
|
||||
- jobs/demo-seed-stock-job.yaml
|
||||
- jobs/demo-seed-quality-templates-job.yaml
|
||||
- jobs/demo-seed-customers-job.yaml
|
||||
- jobs/demo-seed-equipment-job.yaml
|
||||
- jobs/demo-seed-production-batches-job.yaml
|
||||
- jobs/demo-seed-orders-job.yaml
|
||||
- jobs/demo-seed-procurement-job.yaml
|
||||
- jobs/demo-seed-forecasts-job.yaml
|
||||
- jobs/demo-seed-pos-configs-job.yaml
|
||||
- jobs/demo-seed-orchestration-runs-job.yaml
|
||||
# - jobs/demo-seed-alerts-job.yaml # Commented out: Alert processor v2 uses event-driven architecture; services emit events via RabbitMQ
|
||||
|
||||
# Phase 2: Child retail seed jobs (for enterprise demo)
|
||||
- jobs/demo-seed-inventory-retail-job.yaml
|
||||
- jobs/demo-seed-stock-retail-job.yaml
|
||||
- jobs/demo-seed-sales-retail-job.yaml
|
||||
- jobs/demo-seed-customers-retail-job.yaml
|
||||
- jobs/demo-seed-pos-retail-job.yaml
|
||||
- jobs/demo-seed-forecasts-retail-job.yaml
|
||||
# - jobs/demo-seed-alerts-retail-job.yaml # Commented out: Alert processor v2 uses event-driven architecture; services emit events via RabbitMQ
|
||||
- jobs/demo-seed-distribution-history-job.yaml
|
||||
|
||||
# External data initialization job (v2.0)
|
||||
- jobs/external-data-init-job.yaml
|
||||
|
||||
|
||||
111
scripts/test/demo_determinism.py
Normal file
111
scripts/test/demo_determinism.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test deterministic cloning by creating multiple sessions and comparing data hashes.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import List, Dict
|
||||
import httpx
|
||||
|
||||
DEMO_API_URL = "http://localhost:8018"
|
||||
INTERNAL_API_KEY = "test-internal-key"
|
||||
|
||||
async def create_demo_session(tier: str = "professional") -> dict:
|
||||
"""Create a demo session"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{DEMO_API_URL}/api/demo/sessions",
|
||||
json={"demo_account_type": tier}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_all_data_from_service(
|
||||
service_url: str,
|
||||
tenant_id: str
|
||||
) -> dict:
|
||||
"""Fetch all data for a tenant from a service"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{service_url}/internal/demo/export/{tenant_id}",
|
||||
headers={"X-Internal-API-Key": INTERNAL_API_KEY}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def calculate_data_hash(data: dict) -> str:
|
||||
"""
|
||||
Calculate SHA-256 hash of data, excluding audit timestamps.
|
||||
"""
|
||||
# Remove non-deterministic fields
|
||||
clean_data = remove_audit_fields(data)
|
||||
|
||||
# Sort keys for consistency
|
||||
json_str = json.dumps(clean_data, sort_keys=True)
|
||||
|
||||
return hashlib.sha256(json_str.encode()).hexdigest()
|
||||
|
||||
def remove_audit_fields(data: dict) -> dict:
|
||||
"""Remove created_at, updated_at fields recursively"""
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
k: remove_audit_fields(v)
|
||||
for k, v in data.items()
|
||||
if k not in ["created_at", "updated_at", "id"] # IDs are UUIDs
|
||||
}
|
||||
elif isinstance(data, list):
|
||||
return [remove_audit_fields(item) for item in data]
|
||||
else:
|
||||
return data
|
||||
|
||||
async def test_determinism(tier: str = "professional", iterations: int = 10):
|
||||
"""
|
||||
Test that cloning is deterministic across multiple sessions.
|
||||
"""
|
||||
print(f"Testing determinism for {tier} tier ({iterations} iterations)...")
|
||||
|
||||
services = [
|
||||
("inventory", "http://inventory-service:8002"),
|
||||
("production", "http://production-service:8003"),
|
||||
("recipes", "http://recipes-service:8004"),
|
||||
]
|
||||
|
||||
hashes_by_service = {svc[0]: [] for svc in services}
|
||||
|
||||
for i in range(iterations):
|
||||
# Create session
|
||||
session = await create_demo_session(tier)
|
||||
tenant_id = session["virtual_tenant_id"]
|
||||
|
||||
# Get data from each service
|
||||
for service_name, service_url in services:
|
||||
data = await get_all_data_from_service(service_url, tenant_id)
|
||||
data_hash = calculate_data_hash(data)
|
||||
hashes_by_service[service_name].append(data_hash)
|
||||
|
||||
# Cleanup
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.delete(f"{DEMO_API_URL}/api/demo/sessions/{session['session_id']}")
|
||||
|
||||
if (i + 1) % 10 == 0:
|
||||
print(f" Completed {i + 1}/{iterations} iterations")
|
||||
|
||||
# Check consistency
|
||||
all_consistent = True
|
||||
for service_name, hashes in hashes_by_service.items():
|
||||
unique_hashes = set(hashes)
|
||||
if len(unique_hashes) == 1:
|
||||
print(f"✅ {service_name}: All {iterations} hashes identical")
|
||||
else:
|
||||
print(f"❌ {service_name}: {len(unique_hashes)} different hashes found!")
|
||||
all_consistent = False
|
||||
|
||||
if all_consistent:
|
||||
print("\n✅ DETERMINISM TEST PASSED")
|
||||
return 0
|
||||
else:
|
||||
print("\n❌ DETERMINISM TEST FAILED")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(test_determinism())
|
||||
exit(exit_code)
|
||||
418
scripts/validate_cross_refs.py
Normal file
418
scripts/validate_cross_refs.py
Normal file
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cross-reference validation script for Bakery-IA demo data.
|
||||
Validates UUID references across different services and fixtures.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
# Configuration
|
||||
BASE_DIR = Path(__file__).parent.parent / "shared" / "demo"
|
||||
FIXTURES_DIR = BASE_DIR / "fixtures" / "professional"
|
||||
METADATA_DIR = BASE_DIR / "metadata"
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Custom exception for validation errors."""
|
||||
pass
|
||||
|
||||
class CrossReferenceValidator:
|
||||
def __init__(self):
|
||||
self.fixtures = {}
|
||||
self.cross_refs_map = {}
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
|
||||
def load_fixtures(self):
|
||||
"""Load all fixture files."""
|
||||
fixture_files = [
|
||||
"01-tenant.json", "02-auth.json", "03-inventory.json",
|
||||
"04-recipes.json", "05-suppliers.json", "06-production.json",
|
||||
"07-procurement.json", "08-orders.json", "09-sales.json",
|
||||
"10-forecasting.json"
|
||||
]
|
||||
|
||||
for filename in fixture_files:
|
||||
filepath = FIXTURES_DIR / filename
|
||||
if filepath.exists():
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
self.fixtures[filename] = json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
self.errors.append(f"Failed to load {filename}: {str(e)}")
|
||||
else:
|
||||
self.warnings.append(f"Fixture file {filename} not found")
|
||||
|
||||
def load_cross_refs_map(self):
|
||||
"""Load cross-reference mapping from metadata."""
|
||||
map_file = METADATA_DIR / "cross_refs_map.json"
|
||||
if map_file.exists():
|
||||
try:
|
||||
with open(map_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.cross_refs_map = data.get("references", [])
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
self.errors.append(f"Failed to load cross_refs_map.json: {str(e)}")
|
||||
else:
|
||||
self.errors.append("cross_refs_map.json not found")
|
||||
|
||||
def is_valid_uuid(self, uuid_str: str) -> bool:
|
||||
"""Check if a string is a valid UUID."""
|
||||
try:
|
||||
UUID(uuid_str)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def get_entity_by_id(self, service: str, entity_type: str, entity_id: str) -> Optional[Dict]:
|
||||
"""Find an entity by ID in the loaded fixtures."""
|
||||
# Map service names to fixture files
|
||||
service_to_fixture = {
|
||||
"inventory": "03-inventory.json",
|
||||
"recipes": "04-recipes.json",
|
||||
"suppliers": "05-suppliers.json",
|
||||
"production": "06-production.json",
|
||||
"procurement": "07-procurement.json",
|
||||
"orders": "08-orders.json",
|
||||
"sales": "09-sales.json",
|
||||
"forecasting": "10-forecasting.json"
|
||||
}
|
||||
|
||||
if service not in service_to_fixture:
|
||||
return None
|
||||
|
||||
fixture_file = service_to_fixture[service]
|
||||
if fixture_file not in self.fixtures:
|
||||
return None
|
||||
|
||||
fixture_data = self.fixtures[fixture_file]
|
||||
|
||||
# Find the entity based on entity_type
|
||||
if entity_type == "Ingredient":
|
||||
return self._find_in_ingredients(fixture_data, entity_id)
|
||||
elif entity_type == "Recipe":
|
||||
return self._find_in_recipes(fixture_data, entity_id)
|
||||
elif entity_type == "Supplier":
|
||||
return self._find_in_suppliers(fixture_data, entity_id)
|
||||
elif entity_type == "ProductionBatch":
|
||||
return self._find_in_production_batches(fixture_data, entity_id)
|
||||
elif entity_type == "PurchaseOrder":
|
||||
return self._find_in_purchase_orders(fixture_data, entity_id)
|
||||
elif entity_type == "Customer":
|
||||
return self._find_in_customers(fixture_data, entity_id)
|
||||
elif entity_type == "SalesData":
|
||||
return self._find_in_sales_data(fixture_data, entity_id)
|
||||
elif entity_type == "Forecast":
|
||||
return self._find_in_forecasts(fixture_data, entity_id)
|
||||
|
||||
return None
|
||||
|
||||
def _find_in_ingredients(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find ingredient by ID."""
|
||||
if "ingredients" in data:
|
||||
for ingredient in data["ingredients"]:
|
||||
if ingredient.get("id") == entity_id:
|
||||
return ingredient
|
||||
return None
|
||||
|
||||
def _find_in_recipes(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find recipe by ID."""
|
||||
if "recipes" in data:
|
||||
for recipe in data["recipes"]:
|
||||
if recipe.get("id") == entity_id:
|
||||
return recipe
|
||||
return None
|
||||
|
||||
def _find_in_suppliers(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find supplier by ID."""
|
||||
if "suppliers" in data:
|
||||
for supplier in data["suppliers"]:
|
||||
if supplier.get("id") == entity_id:
|
||||
return supplier
|
||||
return None
|
||||
|
||||
def _find_in_production_batches(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find production batch by ID."""
|
||||
if "production_batches" in data:
|
||||
for batch in data["production_batches"]:
|
||||
if batch.get("id") == entity_id:
|
||||
return batch
|
||||
return None
|
||||
|
||||
def _find_in_purchase_orders(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find purchase order by ID."""
|
||||
if "purchase_orders" in data:
|
||||
for po in data["purchase_orders"]:
|
||||
if po.get("id") == entity_id:
|
||||
return po
|
||||
return None
|
||||
|
||||
def _find_in_customers(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find customer by ID."""
|
||||
if "customers" in data:
|
||||
for customer in data["customers"]:
|
||||
if customer.get("id") == entity_id:
|
||||
return customer
|
||||
return None
|
||||
|
||||
def _find_in_sales_data(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find sales data by ID."""
|
||||
if "sales_data" in data:
|
||||
for sales in data["sales_data"]:
|
||||
if sales.get("id") == entity_id:
|
||||
return sales
|
||||
return None
|
||||
|
||||
def _find_in_forecasts(self, data: Dict, entity_id: str) -> Optional[Dict]:
|
||||
"""Find forecast by ID."""
|
||||
if "forecasts" in data:
|
||||
for forecast in data["forecasts"]:
|
||||
if forecast.get("id") == entity_id:
|
||||
return forecast
|
||||
return None
|
||||
|
||||
def validate_cross_references(self):
|
||||
"""Validate all cross-references defined in the map."""
|
||||
for ref in self.cross_refs_map:
|
||||
from_service = ref["from_service"]
|
||||
from_entity = ref["from_entity"]
|
||||
from_field = ref["from_field"]
|
||||
to_service = ref["to_service"]
|
||||
to_entity = ref["to_entity"]
|
||||
required = ref.get("required", False)
|
||||
|
||||
# Find all entities of the "from" type
|
||||
entities = self._get_all_entities(from_service, from_entity)
|
||||
|
||||
for entity in entities:
|
||||
ref_id = entity.get(from_field)
|
||||
if not ref_id:
|
||||
if required:
|
||||
self.errors.append(
|
||||
f"{from_entity} {entity.get('id')} missing required field {from_field}"
|
||||
)
|
||||
continue
|
||||
|
||||
if not self.is_valid_uuid(ref_id):
|
||||
self.errors.append(
|
||||
f"{from_entity} {entity.get('id')} has invalid UUID in {from_field}: {ref_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if the referenced entity exists
|
||||
target_entity = self.get_entity_by_id(to_service, to_entity, ref_id)
|
||||
if not target_entity:
|
||||
if required:
|
||||
self.errors.append(
|
||||
f"{from_entity} {entity.get('id')} references non-existent {to_entity} {ref_id}"
|
||||
)
|
||||
else:
|
||||
self.warnings.append(
|
||||
f"{from_entity} {entity.get('id')} references non-existent {to_entity} {ref_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Check filters if specified
|
||||
to_filter = ref.get("to_filter", {})
|
||||
if to_filter:
|
||||
self._validate_filters_case_insensitive(target_entity, to_filter, entity, ref)
|
||||
|
||||
def _get_all_entities(self, service: str, entity_type: str) -> List[Dict]:
|
||||
"""Get all entities of a specific type from a service."""
|
||||
entities = []
|
||||
|
||||
# Map entity types to fixture file and path
|
||||
entity_mapping = {
|
||||
"ProductionBatch": ("06-production.json", "production_batches"),
|
||||
"RecipeIngredient": ("04-recipes.json", "recipe_ingredients"),
|
||||
"Stock": ("03-inventory.json", "stock"),
|
||||
"PurchaseOrder": ("07-procurement.json", "purchase_orders"),
|
||||
"PurchaseOrderItem": ("07-procurement.json", "purchase_order_items"),
|
||||
"OrderItem": ("08-orders.json", "order_items"),
|
||||
"SalesData": ("09-sales.json", "sales_data"),
|
||||
"Forecast": ("10-forecasting.json", "forecasts")
|
||||
}
|
||||
|
||||
if entity_type in entity_mapping:
|
||||
fixture_file, path = entity_mapping[entity_type]
|
||||
if fixture_file in self.fixtures:
|
||||
data = self.fixtures[fixture_file]
|
||||
if path in data:
|
||||
return data[path]
|
||||
|
||||
return entities
|
||||
|
||||
def _validate_filters_case_insensitive(self, target_entity: Dict, filters: Dict, source_entity: Dict, ref: Dict):
|
||||
"""Validate that target entity matches specified filters (case-insensitive)."""
|
||||
for filter_key, filter_value in filters.items():
|
||||
actual_value = target_entity.get(filter_key)
|
||||
if actual_value is None:
|
||||
self.errors.append(
|
||||
f"{source_entity.get('id')} references {target_entity.get('id')} "
|
||||
f"but {filter_key} is missing (expected {filter_value})"
|
||||
)
|
||||
elif str(actual_value).lower() != str(filter_value).lower():
|
||||
self.errors.append(
|
||||
f"{source_entity.get('id')} references {target_entity.get('id')} "
|
||||
f"but {filter_key}={actual_value} != {filter_value}"
|
||||
)
|
||||
|
||||
def validate_required_fields(self):
|
||||
"""Validate required fields in all fixtures."""
|
||||
required_fields_map = {
|
||||
"01-tenant.json": {
|
||||
"tenant": ["id", "name", "subscription_tier"]
|
||||
},
|
||||
"02-auth.json": {
|
||||
"users": ["id", "name", "email", "role"]
|
||||
},
|
||||
"03-inventory.json": {
|
||||
"ingredients": ["id", "name", "product_type", "ingredient_category"],
|
||||
"stock": ["id", "ingredient_id", "quantity", "location"]
|
||||
},
|
||||
"04-recipes.json": {
|
||||
"recipes": ["id", "name", "status", "difficulty_level"],
|
||||
"recipe_ingredients": ["id", "recipe_id", "ingredient_id", "quantity"]
|
||||
},
|
||||
"05-suppliers.json": {
|
||||
"suppliers": ["id", "name", "supplier_code", "status"]
|
||||
},
|
||||
"06-production.json": {
|
||||
"equipment": ["id", "name", "type", "status"],
|
||||
"production_batches": ["id", "product_id", "status", "start_time"]
|
||||
},
|
||||
"07-procurement.json": {
|
||||
"purchase_orders": ["id", "po_number", "supplier_id", "status"],
|
||||
"purchase_order_items": ["id", "purchase_order_id", "inventory_product_id", "ordered_quantity"]
|
||||
},
|
||||
"08-orders.json": {
|
||||
"customers": ["id", "customer_code", "name", "customer_type"],
|
||||
"customer_orders": ["id", "customer_id", "order_number", "status"],
|
||||
"order_items": ["id", "order_id", "product_id", "quantity"]
|
||||
},
|
||||
"09-sales.json": {
|
||||
"sales_data": ["id", "product_id", "quantity_sold", "unit_price"]
|
||||
},
|
||||
"10-forecasting.json": {
|
||||
"forecasts": ["id", "product_id", "forecast_date", "predicted_quantity"]
|
||||
}
|
||||
}
|
||||
|
||||
for filename, required_structure in required_fields_map.items():
|
||||
if filename in self.fixtures:
|
||||
data = self.fixtures[filename]
|
||||
for entity_type, required_fields in required_structure.items():
|
||||
if entity_type in data:
|
||||
entities = data[entity_type]
|
||||
if isinstance(entities, list):
|
||||
for entity in entities:
|
||||
if isinstance(entity, dict):
|
||||
for field in required_fields:
|
||||
if field not in entity:
|
||||
entity_id = entity.get('id', 'unknown')
|
||||
self.errors.append(
|
||||
f"{filename}: {entity_type} {entity_id} missing required field {field}"
|
||||
)
|
||||
elif isinstance(entities, dict):
|
||||
# Handle tenant which is a single dict
|
||||
for field in required_fields:
|
||||
if field not in entities:
|
||||
entity_id = entities.get('id', 'unknown')
|
||||
self.errors.append(
|
||||
f"{filename}: {entity_type} {entity_id} missing required field {field}"
|
||||
)
|
||||
|
||||
def validate_date_formats(self):
|
||||
"""Validate that all dates are in ISO format."""
|
||||
date_fields = [
|
||||
"created_at", "updated_at", "start_time", "end_time",
|
||||
"order_date", "delivery_date", "expected_delivery_date",
|
||||
"sale_date", "forecast_date", "contract_start_date", "contract_end_date"
|
||||
]
|
||||
|
||||
for filename, data in self.fixtures.items():
|
||||
self._check_date_fields(data, date_fields, filename)
|
||||
|
||||
def _check_date_fields(self, data: Any, date_fields: List[str], context: str):
|
||||
"""Recursively check for date fields."""
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if key in date_fields and isinstance(value, str):
|
||||
if not self._is_iso_format(value):
|
||||
self.errors.append(f"{context}: Invalid date format in {key}: {value}")
|
||||
elif isinstance(value, (dict, list)):
|
||||
self._check_date_fields(value, date_fields, context)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
self._check_date_fields(item, date_fields, context)
|
||||
|
||||
def _is_iso_format(self, date_str: str) -> bool:
|
||||
"""Check if a string is in ISO format or BASE_TS marker."""
|
||||
try:
|
||||
# Accept BASE_TS markers (e.g., "BASE_TS - 1h", "BASE_TS + 2d")
|
||||
if date_str.startswith("BASE_TS"):
|
||||
return True
|
||||
|
||||
# Accept offset-based dates (used in some fixtures)
|
||||
if "_offset_" in date_str:
|
||||
return True
|
||||
|
||||
# Simple check for ISO format (YYYY-MM-DDTHH:MM:SSZ or similar)
|
||||
if len(date_str) < 19:
|
||||
return False
|
||||
return date_str.endswith('Z') and date_str[10] == 'T'
|
||||
except:
|
||||
return False
|
||||
|
||||
def run_validation(self) -> bool:
|
||||
"""Run all validation checks."""
|
||||
print("🔍 Starting cross-reference validation...")
|
||||
|
||||
# Load data
|
||||
self.load_fixtures()
|
||||
self.load_cross_refs_map()
|
||||
|
||||
if self.errors:
|
||||
print("❌ Errors during data loading:")
|
||||
for error in self.errors:
|
||||
print(f" - {error}")
|
||||
return False
|
||||
|
||||
# Run validation checks
|
||||
print("📋 Validating cross-references...")
|
||||
self.validate_cross_references()
|
||||
|
||||
print("📝 Validating required fields...")
|
||||
self.validate_required_fields()
|
||||
|
||||
print("📅 Validating date formats...")
|
||||
self.validate_date_formats()
|
||||
|
||||
# Report results
|
||||
if self.errors:
|
||||
print(f"\n❌ Validation failed with {len(self.errors)} errors:")
|
||||
for error in self.errors:
|
||||
print(f" - {error}")
|
||||
|
||||
if self.warnings:
|
||||
print(f"\n⚠️ {len(self.warnings)} warnings:")
|
||||
for warning in self.warnings:
|
||||
print(f" - {warning}")
|
||||
|
||||
return False
|
||||
else:
|
||||
print("\n✅ All validation checks passed!")
|
||||
if self.warnings:
|
||||
print(f"⚠️ {len(self.warnings)} warnings:")
|
||||
for warning in self.warnings:
|
||||
print(f" - {warning}")
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
validator = CrossReferenceValidator()
|
||||
success = validator.run_validation()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -7,6 +7,7 @@ the enrichment pipeline.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from aio_pika import connect_robust, IncomingMessage, Connection, Channel
|
||||
import structlog
|
||||
|
||||
@@ -112,9 +113,64 @@ class EventConsumer:
|
||||
# Enrich the event
|
||||
enriched_event = await self.enricher.enrich_event(event)
|
||||
|
||||
# Store in database
|
||||
# Check for duplicate alerts before storing
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = EventRepository(session)
|
||||
|
||||
# Check for duplicate if it's an alert
|
||||
if event.event_class == "alert":
|
||||
from uuid import UUID
|
||||
duplicate_event = await repo.check_duplicate_alert(
|
||||
tenant_id=UUID(event.tenant_id),
|
||||
event_type=event.event_type,
|
||||
entity_links=enriched_event.entity_links,
|
||||
event_metadata=enriched_event.event_metadata,
|
||||
time_window_hours=24 # Check for duplicates in last 24 hours
|
||||
)
|
||||
|
||||
if duplicate_event:
|
||||
logger.info(
|
||||
"Duplicate alert detected, skipping",
|
||||
event_type=event.event_type,
|
||||
tenant_id=event.tenant_id,
|
||||
duplicate_event_id=str(duplicate_event.id)
|
||||
)
|
||||
# Update the existing event's metadata instead of creating a new one
|
||||
# This could include updating delay times, affected orders, etc.
|
||||
duplicate_event.event_metadata = enriched_event.event_metadata
|
||||
duplicate_event.updated_at = datetime.now(timezone.utc)
|
||||
duplicate_event.priority_score = enriched_event.priority_score
|
||||
duplicate_event.priority_level = enriched_event.priority_level
|
||||
|
||||
# Update other relevant fields that might have changed
|
||||
duplicate_event.urgency = enriched_event.urgency.dict() if enriched_event.urgency else None
|
||||
duplicate_event.business_impact = enriched_event.business_impact.dict() if enriched_event.business_impact else None
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(duplicate_event)
|
||||
|
||||
# Send notification for updated event
|
||||
await self._send_notification(duplicate_event)
|
||||
|
||||
# Publish to SSE
|
||||
await self.sse_svc.publish_event(duplicate_event)
|
||||
|
||||
logger.info(
|
||||
"Duplicate alert updated",
|
||||
event_id=str(duplicate_event.id),
|
||||
event_type=event.event_type,
|
||||
priority_level=duplicate_event.priority_level,
|
||||
priority_score=duplicate_event.priority_score
|
||||
)
|
||||
return # Exit early since we handled the duplicate
|
||||
else:
|
||||
logger.info(
|
||||
"New unique alert, proceeding with creation",
|
||||
event_type=event.event_type,
|
||||
tenant_id=event.tenant_id
|
||||
)
|
||||
|
||||
# Store in database (if not a duplicate)
|
||||
stored_event = await repo.create_event(enriched_event)
|
||||
|
||||
# Send to notification service (if alert)
|
||||
|
||||
@@ -148,6 +148,107 @@ class EventRepository:
|
||||
result = await self.session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def check_duplicate_alert(self, tenant_id: UUID, event_type: str, entity_links: Dict, event_metadata: Dict, time_window_hours: int = 24) -> Optional[Event]:
|
||||
"""
|
||||
Check if a similar alert already exists within the time window.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
event_type: Type of event (e.g., 'production_delay', 'critical_stock_shortage')
|
||||
entity_links: Entity references (e.g., batch_id, po_id, ingredient_id)
|
||||
event_metadata: Event metadata for comparison
|
||||
time_window_hours: Time window in hours to check for duplicates
|
||||
|
||||
Returns:
|
||||
Existing event if duplicate found, None otherwise
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Calculate time threshold
|
||||
time_threshold = datetime.now(timezone.utc) - timedelta(hours=time_window_hours)
|
||||
|
||||
# Build query to find potential duplicates
|
||||
query = select(Event).where(
|
||||
and_(
|
||||
Event.tenant_id == tenant_id,
|
||||
Event.event_type == event_type,
|
||||
Event.status == "active", # Only check active alerts
|
||||
Event.created_at >= time_threshold
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
potential_duplicates = result.scalars().all()
|
||||
|
||||
# Compare each potential duplicate for semantic similarity
|
||||
for event in potential_duplicates:
|
||||
# Check if entity links match (same batch, PO, ingredient, etc.)
|
||||
if self._entities_match(event.entity_links, entity_links):
|
||||
# For production delays, check if it's the same batch with similar delay
|
||||
if event_type == "production_delay":
|
||||
if self._production_delay_match(event.event_metadata, event_metadata):
|
||||
return event
|
||||
|
||||
# For critical stock shortages, check if it's the same ingredient
|
||||
elif event_type == "critical_stock_shortage":
|
||||
if self._stock_shortage_match(event.event_metadata, event_metadata):
|
||||
return event
|
||||
|
||||
# For delivery overdue alerts, check if it's the same PO
|
||||
elif event_type == "delivery_overdue":
|
||||
if self._delivery_overdue_match(event.event_metadata, event_metadata):
|
||||
return event
|
||||
|
||||
# For general matching based on metadata
|
||||
else:
|
||||
if self._metadata_match(event.event_metadata, event_metadata):
|
||||
return event
|
||||
|
||||
return None
|
||||
|
||||
def _entities_match(self, existing_links: Dict, new_links: Dict) -> bool:
|
||||
"""Check if entity links match between two events."""
|
||||
if not existing_links or not new_links:
|
||||
return False
|
||||
|
||||
# Check for common entity types
|
||||
common_entities = ['production_batch', 'purchase_order', 'ingredient', 'supplier', 'equipment']
|
||||
|
||||
for entity in common_entities:
|
||||
if entity in existing_links and entity in new_links:
|
||||
if existing_links[entity] == new_links[entity]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _production_delay_match(self, existing_meta: Dict, new_meta: Dict) -> bool:
|
||||
"""Check if production delay alerts match."""
|
||||
# Same batch_id indicates same production issue
|
||||
return (existing_meta.get('batch_id') == new_meta.get('batch_id') and
|
||||
existing_meta.get('product_name') == new_meta.get('product_name'))
|
||||
|
||||
def _stock_shortage_match(self, existing_meta: Dict, new_meta: Dict) -> bool:
|
||||
"""Check if stock shortage alerts match."""
|
||||
# Same ingredient_id indicates same shortage issue
|
||||
return existing_meta.get('ingredient_id') == new_meta.get('ingredient_id')
|
||||
|
||||
def _delivery_overdue_match(self, existing_meta: Dict, new_meta: Dict) -> bool:
|
||||
"""Check if delivery overdue alerts match."""
|
||||
# Same PO indicates same delivery issue
|
||||
return existing_meta.get('po_id') == new_meta.get('po_id')
|
||||
|
||||
def _metadata_match(self, existing_meta: Dict, new_meta: Dict) -> bool:
|
||||
"""Generic metadata matching for other alert types."""
|
||||
# Check for common identifying fields
|
||||
common_fields = ['batch_id', 'po_id', 'ingredient_id', 'supplier_id', 'equipment_id']
|
||||
|
||||
for field in common_fields:
|
||||
if field in existing_meta and field in new_meta:
|
||||
if existing_meta[field] == new_meta[field]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def get_summary(self, tenant_id: UUID) -> EventSummary:
|
||||
"""
|
||||
Get summary statistics for dashboard.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .internal_demo import router as internal_demo_router
|
||||
|
||||
__all__ = ["internal_demo_router"]
|
||||
244
services/auth/app/api/internal_demo.py
Normal file
244
services/auth/app/api/internal_demo.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
Internal Demo Cloning API for Auth Service
|
||||
Service-to-service endpoint for cloning authentication and user data
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add shared path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.users import User
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
# Base demo tenant IDs
|
||||
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
||||
|
||||
|
||||
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
if x_internal_api_key != settings.INTERNAL_API_KEY:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/internal/demo/clone")
|
||||
async def clone_demo_data(
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
demo_account_type: str,
|
||||
session_id: Optional[str] = None,
|
||||
session_created_at: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Clone auth service data for a virtual demo tenant
|
||||
|
||||
Clones:
|
||||
- Demo users (owner and staff)
|
||||
|
||||
Note: Tenant memberships are handled by the tenant service's internal_demo endpoint
|
||||
|
||||
Args:
|
||||
base_tenant_id: Template tenant UUID to clone from
|
||||
virtual_tenant_id: Target virtual tenant UUID
|
||||
demo_account_type: Type of demo account
|
||||
session_id: Originating session ID for tracing
|
||||
|
||||
Returns:
|
||||
Cloning status and record counts
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Parse session creation time
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
session_time = start_time
|
||||
else:
|
||||
session_time = start_time
|
||||
|
||||
logger.info(
|
||||
"Starting auth data cloning",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
demo_account_type=demo_account_type,
|
||||
session_id=session_id,
|
||||
session_created_at=session_created_at
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate UUIDs
|
||||
base_uuid = uuid.UUID(base_tenant_id)
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
|
||||
# Note: We don't check for existing users since User model doesn't have demo_session_id
|
||||
# Demo users are identified by their email addresses from the seed data
|
||||
# Idempotency is handled by checking if each user email already exists below
|
||||
|
||||
# Load demo users from JSON seed file
|
||||
try:
|
||||
from shared.utils.seed_data_paths import get_seed_data_path
|
||||
|
||||
if demo_account_type == "professional":
|
||||
json_file = get_seed_data_path("professional", "02-auth.json")
|
||||
elif demo_account_type == "enterprise":
|
||||
json_file = get_seed_data_path("enterprise", "02-auth.json")
|
||||
else:
|
||||
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
||||
|
||||
except ImportError:
|
||||
# Fallback to original path
|
||||
seed_data_dir = Path(__file__).parent.parent.parent.parent / "infrastructure" / "seed-data"
|
||||
if demo_account_type == "professional":
|
||||
json_file = seed_data_dir / "professional" / "02-auth.json"
|
||||
elif demo_account_type == "enterprise":
|
||||
json_file = seed_data_dir / "enterprise" / "parent" / "02-auth.json"
|
||||
else:
|
||||
raise ValueError(f"Invalid demo account type: {demo_account_type}")
|
||||
|
||||
if not json_file.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Seed data file not found: {json_file}"
|
||||
)
|
||||
|
||||
# Load JSON data
|
||||
import json
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
seed_data = json.load(f)
|
||||
|
||||
# Get demo users for this account type
|
||||
demo_users_data = seed_data.get("users", [])
|
||||
|
||||
records_cloned = 0
|
||||
|
||||
# Create users and tenant memberships
|
||||
for user_data in demo_users_data:
|
||||
user_id = uuid.UUID(user_data["id"])
|
||||
|
||||
# Create user if not exists
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
existing_user = user_result.scalars().first()
|
||||
|
||||
if not existing_user:
|
||||
# Apply date adjustments to created_at and updated_at
|
||||
from shared.utils.demo_dates import adjust_date_for_demo
|
||||
|
||||
# Adjust created_at date
|
||||
created_at_str = user_data.get("created_at", session_time.isoformat())
|
||||
if isinstance(created_at_str, str):
|
||||
try:
|
||||
original_created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
|
||||
adjusted_created_at = adjust_date_for_demo(original_created_at, session_time)
|
||||
except ValueError:
|
||||
adjusted_created_at = session_time
|
||||
else:
|
||||
adjusted_created_at = session_time
|
||||
|
||||
# Adjust updated_at date (same as created_at for demo users)
|
||||
adjusted_updated_at = adjusted_created_at
|
||||
|
||||
# Get full_name from either "name" or "full_name" field
|
||||
full_name = user_data.get("full_name") or user_data.get("name", "Demo User")
|
||||
|
||||
# For demo users, use a placeholder hashed password (they won't actually log in)
|
||||
# In production, this would be properly hashed
|
||||
demo_hashed_password = "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYqNlI.eFKW" # "demo_password"
|
||||
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=user_data["email"],
|
||||
full_name=full_name,
|
||||
hashed_password=demo_hashed_password,
|
||||
is_active=user_data.get("is_active", True),
|
||||
is_verified=True,
|
||||
role=user_data.get("role", "member"),
|
||||
language=user_data.get("language", "es"),
|
||||
timezone=user_data.get("timezone", "Europe/Madrid"),
|
||||
created_at=adjusted_created_at,
|
||||
updated_at=adjusted_updated_at
|
||||
)
|
||||
db.add(user)
|
||||
records_cloned += 1
|
||||
|
||||
# Note: Tenant memberships are handled by tenant service
|
||||
# Only create users in auth service
|
||||
|
||||
await db.commit()
|
||||
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Auth data cloning completed",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
session_id=session_id,
|
||||
records_cloned=records_cloned,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "auth",
|
||||
"status": "completed",
|
||||
"records_cloned": records_cloned,
|
||||
"base_tenant_id": str(base_tenant_id),
|
||||
"virtual_tenant_id": str(virtual_tenant_id),
|
||||
"session_id": session_id,
|
||||
"demo_account_type": demo_account_type,
|
||||
"duration_ms": duration_ms
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to clone auth data",
|
||||
error=str(e),
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Rollback on error
|
||||
await db.rollback()
|
||||
|
||||
return {
|
||||
"service": "auth",
|
||||
"status": "failed",
|
||||
"records_cloned": 0,
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clone/health")
|
||||
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
|
||||
"""
|
||||
Health check for internal cloning endpoint
|
||||
Used by orchestrator to verify service availability
|
||||
"""
|
||||
return {
|
||||
"service": "auth",
|
||||
"clone_endpoint": "available",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
from app.core.database import database_manager
|
||||
from app.api import auth_operations, users, onboarding_progress, consent, data_export, account_deletion
|
||||
from app.api import auth_operations, users, onboarding_progress, consent, data_export, account_deletion, internal_demo
|
||||
from shared.service_base import StandardFastAPIService
|
||||
from shared.messaging import UnifiedEventPublisher
|
||||
|
||||
@@ -169,3 +169,4 @@ service.add_router(onboarding_progress.router, tags=["onboarding"])
|
||||
service.add_router(consent.router, tags=["gdpr", "consent"])
|
||||
service.add_router(data_export.router, tags=["gdpr", "data-export"])
|
||||
service.add_router(account_deletion.router, tags=["gdpr", "account-deletion"])
|
||||
service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Seed Demo Users
|
||||
Creates demo user accounts for production demo environment
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import os
|
||||
os.environ.setdefault("AUTH_DATABASE_URL", os.getenv("AUTH_DATABASE_URL"))
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
import uuid
|
||||
import json
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Demo user configurations (public credentials for prospects)
|
||||
DEMO_USERS = [
|
||||
{
|
||||
"id": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6",
|
||||
"email": "demo.individual@panaderiasanpablo.com",
|
||||
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi", # DemoSanPablo2024!
|
||||
"full_name": "María García López",
|
||||
"phone": "+34 912 345 678",
|
||||
"language": "es",
|
||||
"timezone": "Europe/Madrid",
|
||||
"role": "owner",
|
||||
"is_active": True,
|
||||
"is_verified": True,
|
||||
"is_demo": True
|
||||
},
|
||||
{
|
||||
"id": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7",
|
||||
"email": "demo.central@panaderialaespiga.com",
|
||||
"password_hash": "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYVPWzO8hGi", # DemoLaEspiga2024!
|
||||
"full_name": "Carlos Martínez Ruiz",
|
||||
"phone": "+34 913 456 789",
|
||||
"language": "es",
|
||||
"timezone": "Europe/Madrid",
|
||||
"role": "owner",
|
||||
"is_active": True,
|
||||
"is_verified": True,
|
||||
"is_demo": True
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def load_staff_users():
|
||||
"""Load staff users from JSON file"""
|
||||
json_file = Path(__file__).parent / "usuarios_staff_es.json"
|
||||
if not json_file.exists():
|
||||
logger.warning(f"Staff users JSON not found: {json_file}, skipping staff users")
|
||||
return []
|
||||
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Combine both individual and central bakery staff
|
||||
all_staff = data.get("staff_individual_bakery", []) + data.get("staff_central_bakery", [])
|
||||
logger.info(f"Loaded {len(all_staff)} staff users from JSON")
|
||||
return all_staff
|
||||
|
||||
|
||||
async def seed_demo_users():
|
||||
"""Seed demo users into auth database"""
|
||||
|
||||
database_url = os.getenv("AUTH_DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("AUTH_DATABASE_URL environment variable not set")
|
||||
return False
|
||||
|
||||
logger.info("Connecting to auth database", url=database_url.split("@")[-1])
|
||||
|
||||
engine = create_async_engine(database_url, echo=False)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
# Import User model
|
||||
try:
|
||||
from app.models.users import User
|
||||
except ImportError:
|
||||
from services.auth.app.models.users import User
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Load staff users from JSON
|
||||
staff_users = load_staff_users()
|
||||
|
||||
# Combine owner users with staff users
|
||||
all_users = DEMO_USERS + staff_users
|
||||
logger.info(f"Seeding {len(all_users)} total users ({len(DEMO_USERS)} owners + {len(staff_users)} staff)")
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for user_data in all_users:
|
||||
# Check if user already exists
|
||||
result = await session.execute(
|
||||
select(User).where(User.email == user_data["email"])
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
logger.debug(f"Demo user already exists: {user_data['email']}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create new demo user
|
||||
user = User(
|
||||
id=uuid.UUID(user_data["id"]),
|
||||
email=user_data["email"],
|
||||
hashed_password=user_data["password_hash"],
|
||||
full_name=user_data["full_name"],
|
||||
phone=user_data.get("phone"),
|
||||
language=user_data.get("language", "es"),
|
||||
timezone=user_data.get("timezone", "Europe/Madrid"),
|
||||
role=user_data.get("role", "owner"),
|
||||
is_active=user_data.get("is_active", True),
|
||||
is_verified=user_data.get("is_verified", True),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
created_count += 1
|
||||
logger.debug(f"Created demo user: {user_data['email']} ({user_data.get('role', 'owner')})")
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"Demo users seeded successfully: {created_count} created, {skipped_count} skipped")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seed demo users: {str(e)}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(seed_demo_users())
|
||||
sys.exit(0 if result else 1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,10 +25,17 @@ route_builder = RouteBuilder('demo')
|
||||
async def _background_cloning_task(session_id: str, session_obj_id: UUID, base_tenant_id: str):
|
||||
"""Background task for orchestrated cloning - creates its own DB session"""
|
||||
from app.core.database import db_manager
|
||||
from app.models import DemoSession
|
||||
from sqlalchemy import select
|
||||
from app.models import DemoSession, DemoSessionStatus
|
||||
from sqlalchemy import select, update
|
||||
from app.core.redis_wrapper import get_redis
|
||||
|
||||
logger.info(
|
||||
"Starting background cloning task",
|
||||
session_id=session_id,
|
||||
session_obj_id=str(session_obj_id),
|
||||
base_tenant_id=base_tenant_id
|
||||
)
|
||||
|
||||
# Create new database session for background task
|
||||
async with db_manager.session_factory() as db:
|
||||
try:
|
||||
@@ -43,8 +50,30 @@ async def _background_cloning_task(session_id: str, session_obj_id: UUID, base_t
|
||||
|
||||
if not session:
|
||||
logger.error("Session not found for cloning", session_id=session_id)
|
||||
# Mark session as failed in Redis for frontend polling
|
||||
try:
|
||||
client = await redis.get_client()
|
||||
status_key = f"session:{session_id}:status"
|
||||
import json
|
||||
status_data = {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"error": "Session not found in database",
|
||||
"progress": {},
|
||||
"total_records_cloned": 0
|
||||
}
|
||||
await client.setex(status_key, 7200, json.dumps(status_data))
|
||||
except Exception as redis_error:
|
||||
logger.error("Failed to update Redis status for missing session", error=str(redis_error))
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Found session for cloning",
|
||||
session_id=session_id,
|
||||
current_status=session.status.value,
|
||||
demo_account_type=session.demo_account_type
|
||||
)
|
||||
|
||||
# Create session manager with new DB session
|
||||
session_manager = DemoSessionManager(db, redis)
|
||||
await session_manager.trigger_orchestrated_cloning(session, base_tenant_id)
|
||||
@@ -58,19 +87,15 @@ async def _background_cloning_task(session_id: str, session_obj_id: UUID, base_t
|
||||
)
|
||||
# Attempt to update session status to failed if possible
|
||||
try:
|
||||
from app.core.database import db_manager
|
||||
from app.models import DemoSession
|
||||
from sqlalchemy import select, update
|
||||
|
||||
# Try to update the session directly in DB to mark it as failed
|
||||
async with db_manager.session_factory() as update_db:
|
||||
from app.models import DemoSessionStatus
|
||||
update_result = await update_db.execute(
|
||||
update(DemoSession)
|
||||
.where(DemoSession.id == session_obj_id)
|
||||
.values(status=DemoSessionStatus.FAILED, cloning_completed_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await update_db.commit()
|
||||
logger.info("Successfully updated session status to FAILED in database")
|
||||
except Exception as update_error:
|
||||
logger.error(
|
||||
"Failed to update session status to FAILED after background task error",
|
||||
@@ -78,6 +103,25 @@ async def _background_cloning_task(session_id: str, session_obj_id: UUID, base_t
|
||||
error=str(update_error)
|
||||
)
|
||||
|
||||
# Also update Redis status for frontend polling
|
||||
try:
|
||||
client = await redis.get_client()
|
||||
status_key = f"session:{session_id}:status"
|
||||
import json
|
||||
status_data = {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"progress": {},
|
||||
"total_records_cloned": 0,
|
||||
"cloning_completed_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
await client.setex(status_key, 7200, json.dumps(status_data))
|
||||
logger.info("Successfully updated Redis status to FAILED")
|
||||
except Exception as redis_error:
|
||||
logger.error("Failed to update Redis status after background task error", error=str(redis_error))
|
||||
|
||||
|
||||
|
||||
def _handle_task_result(task, session_id: str):
|
||||
"""Handle the result of the background cloning task"""
|
||||
@@ -92,6 +136,36 @@ def _handle_task_result(task, session_id: str):
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Try to update Redis status to reflect the failure
|
||||
try:
|
||||
from app.core.redis_wrapper import get_redis
|
||||
import json
|
||||
|
||||
async def update_redis_status():
|
||||
redis = await get_redis()
|
||||
client = await redis.get_client()
|
||||
status_key = f"session:{session_id}:status"
|
||||
status_data = {
|
||||
"session_id": session_id,
|
||||
"status": "failed",
|
||||
"error": f"Task exception: {str(e)}",
|
||||
"progress": {},
|
||||
"total_records_cloned": 0,
|
||||
"cloning_completed_at": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
await client.setex(status_key, 7200, json.dumps(status_data))
|
||||
|
||||
# Run the async function
|
||||
import asyncio
|
||||
asyncio.run(update_redis_status())
|
||||
|
||||
except Exception as redis_error:
|
||||
logger.error(
|
||||
"Failed to update Redis status in task result handler",
|
||||
session_id=session_id,
|
||||
error=str(redis_error)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("sessions", include_tenant_prefix=False),
|
||||
@@ -209,6 +283,123 @@ async def get_session_status(
|
||||
return status
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("sessions", "session_id", include_tenant_prefix=False) + "/errors",
|
||||
response_model=dict
|
||||
)
|
||||
async def get_session_errors(
|
||||
session_id: str = Path(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: DemoRedisWrapper = Depends(get_redis)
|
||||
):
|
||||
"""
|
||||
Get detailed error information for a failed demo session
|
||||
|
||||
Returns comprehensive error details including:
|
||||
- Failed services and their specific errors
|
||||
- Network connectivity issues
|
||||
- Timeout problems
|
||||
- Service-specific error messages
|
||||
"""
|
||||
try:
|
||||
# Try to get the session first
|
||||
session_manager = DemoSessionManager(db, redis)
|
||||
session = await session_manager.get_session(session_id)
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
# Check if session has failed status
|
||||
if session.status != DemoSessionStatus.FAILED:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": session.status.value,
|
||||
"has_errors": False,
|
||||
"message": "Session has not failed - no error details available"
|
||||
}
|
||||
|
||||
# Get detailed error information from cloning progress
|
||||
error_details = []
|
||||
failed_services = []
|
||||
|
||||
if session.cloning_progress:
|
||||
for service_name, service_data in session.cloning_progress.items():
|
||||
if isinstance(service_data, dict) and service_data.get("status") == "failed":
|
||||
failed_services.append(service_name)
|
||||
error_details.append({
|
||||
"service": service_name,
|
||||
"error": service_data.get("error", "Unknown error"),
|
||||
"response_status": service_data.get("response_status"),
|
||||
"response_text": service_data.get("response_text", ""),
|
||||
"duration_ms": service_data.get("duration_ms", 0)
|
||||
})
|
||||
|
||||
# Check Redis for additional error information
|
||||
client = await redis.get_client()
|
||||
error_key = f"session:{session_id}:errors"
|
||||
redis_errors = await client.get(error_key)
|
||||
|
||||
if redis_errors:
|
||||
import json
|
||||
try:
|
||||
additional_errors = json.loads(redis_errors)
|
||||
if isinstance(additional_errors, list):
|
||||
error_details.extend(additional_errors)
|
||||
elif isinstance(additional_errors, dict):
|
||||
error_details.append(additional_errors)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse Redis error data", session_id=session_id)
|
||||
|
||||
# Create comprehensive error report
|
||||
error_report = {
|
||||
"session_id": session_id,
|
||||
"status": session.status.value,
|
||||
"has_errors": True,
|
||||
"failed_services": failed_services,
|
||||
"error_count": len(error_details),
|
||||
"errors": error_details,
|
||||
"cloning_started_at": session.cloning_started_at.isoformat() if session.cloning_started_at else None,
|
||||
"cloning_completed_at": session.cloning_completed_at.isoformat() if session.cloning_completed_at else None,
|
||||
"total_records_cloned": session.total_records_cloned,
|
||||
"demo_account_type": session.demo_account_type
|
||||
}
|
||||
|
||||
# Add troubleshooting suggestions
|
||||
suggestions = []
|
||||
if "tenant" in failed_services:
|
||||
suggestions.append("Check if tenant service is running and accessible")
|
||||
suggestions.append("Verify base tenant ID configuration")
|
||||
if "auth" in failed_services:
|
||||
suggestions.append("Check if auth service is running and accessible")
|
||||
suggestions.append("Verify seed data files for auth service")
|
||||
if any(svc in failed_services for svc in ["inventory", "recipes", "suppliers", "production"]):
|
||||
suggestions.append("Check if the specific service is running and accessible")
|
||||
suggestions.append("Verify seed data files exist and are valid")
|
||||
if any("timeout" in error.get("error", "").lower() for error in error_details):
|
||||
suggestions.append("Check service response times and consider increasing timeouts")
|
||||
suggestions.append("Verify network connectivity between services")
|
||||
if any("network" in error.get("error", "").lower() for error in error_details):
|
||||
suggestions.append("Check network connectivity between demo-session and other services")
|
||||
suggestions.append("Verify DNS resolution and service discovery")
|
||||
|
||||
if suggestions:
|
||||
error_report["troubleshooting_suggestions"] = suggestions
|
||||
|
||||
return error_report
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to retrieve session errors",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve error details: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_resource_detail_route("sessions", "session_id", include_tenant_prefix=False) + "/retry",
|
||||
response_model=dict
|
||||
|
||||
@@ -9,7 +9,7 @@ import structlog
|
||||
|
||||
from app.core import get_db, settings
|
||||
from app.core.redis_wrapper import get_redis, DemoRedisWrapper
|
||||
from app.services.data_cloner import DemoDataCloner
|
||||
from app.services.cleanup_service import DemoCleanupService
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
@@ -51,14 +51,21 @@ async def cleanup_demo_session_internal(
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
data_cloner = DemoDataCloner(db, redis)
|
||||
cleanup_service = DemoCleanupService(db, redis)
|
||||
|
||||
# Validate required fields
|
||||
if not tenant_id or not session_id:
|
||||
raise ValueError("tenant_id and session_id are required")
|
||||
|
||||
# Delete session data for this tenant
|
||||
await data_cloner.delete_session_data(
|
||||
str(tenant_id),
|
||||
session_id
|
||||
await cleanup_service._delete_tenant_data(
|
||||
tenant_id=str(tenant_id),
|
||||
session_id=str(session_id)
|
||||
)
|
||||
|
||||
# Delete Redis data
|
||||
await redis.delete_session_data(str(session_id))
|
||||
|
||||
logger.info(
|
||||
"Internal cleanup completed",
|
||||
tenant_id=tenant_id,
|
||||
|
||||
@@ -48,23 +48,23 @@ class Settings(BaseServiceSettings):
|
||||
"email": "demo.enterprise@panaderiacentral.com",
|
||||
"name": "Panadería Central - Demo Enterprise",
|
||||
"subdomain": "demo-central",
|
||||
"base_tenant_id": "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8",
|
||||
"base_tenant_id": "80000000-0000-4000-a000-000000000001",
|
||||
"subscription_tier": "enterprise",
|
||||
"tenant_type": "parent",
|
||||
"children": [
|
||||
{
|
||||
"name": "Madrid Centro",
|
||||
"base_tenant_id": "d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9",
|
||||
"base_tenant_id": "A0000000-0000-4000-a000-000000000001",
|
||||
"location": {"city": "Madrid", "zone": "Centro", "latitude": 40.4168, "longitude": -3.7038}
|
||||
},
|
||||
{
|
||||
"name": "Barcelona Gràcia",
|
||||
"base_tenant_id": "e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0",
|
||||
"base_tenant_id": "B0000000-0000-4000-a000-000000000001",
|
||||
"location": {"city": "Barcelona", "zone": "Gràcia", "latitude": 41.4036, "longitude": 2.1561}
|
||||
},
|
||||
{
|
||||
"name": "Valencia Ruzafa",
|
||||
"base_tenant_id": "f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1",
|
||||
"base_tenant_id": "C0000000-0000-4000-a000-000000000001",
|
||||
"location": {"city": "Valencia", "zone": "Ruzafa", "latitude": 39.4623, "longitude": -0.3645}
|
||||
}
|
||||
]
|
||||
|
||||
85
services/demo_session/app/monitoring/metrics.py
Normal file
85
services/demo_session/app/monitoring/metrics.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Prometheus metrics for demo session service
|
||||
"""
|
||||
|
||||
from prometheus_client import Counter, Histogram, Gauge
|
||||
|
||||
# Counters
|
||||
demo_sessions_created_total = Counter(
|
||||
'demo_sessions_created_total',
|
||||
'Total number of demo sessions created',
|
||||
['tier', 'status']
|
||||
)
|
||||
|
||||
demo_sessions_deleted_total = Counter(
|
||||
'demo_sessions_deleted_total',
|
||||
'Total number of demo sessions deleted',
|
||||
['tier', 'status']
|
||||
)
|
||||
|
||||
demo_cloning_errors_total = Counter(
|
||||
'demo_cloning_errors_total',
|
||||
'Total number of cloning errors',
|
||||
['tier', 'service', 'error_type']
|
||||
)
|
||||
|
||||
# Histograms (for latency percentiles)
|
||||
demo_session_creation_duration_seconds = Histogram(
|
||||
'demo_session_creation_duration_seconds',
|
||||
'Duration of demo session creation',
|
||||
['tier'],
|
||||
buckets=[1, 2, 5, 7, 10, 12, 15, 18, 20, 25, 30, 40, 50, 60]
|
||||
)
|
||||
|
||||
demo_service_clone_duration_seconds = Histogram(
|
||||
'demo_service_clone_duration_seconds',
|
||||
'Duration of individual service cloning',
|
||||
['tier', 'service'],
|
||||
buckets=[0.5, 1, 2, 3, 5, 10, 15, 20, 30, 40, 50]
|
||||
)
|
||||
|
||||
demo_session_cleanup_duration_seconds = Histogram(
|
||||
'demo_session_cleanup_duration_seconds',
|
||||
'Duration of demo session cleanup',
|
||||
['tier'],
|
||||
buckets=[0.5, 1, 2, 5, 10, 15, 20, 30]
|
||||
)
|
||||
|
||||
# Gauges
|
||||
demo_sessions_active = Gauge(
|
||||
'demo_sessions_active',
|
||||
'Number of currently active demo sessions',
|
||||
['tier']
|
||||
)
|
||||
|
||||
demo_sessions_pending_cleanup = Gauge(
|
||||
'demo_sessions_pending_cleanup',
|
||||
'Number of demo sessions pending cleanup'
|
||||
)
|
||||
|
||||
# Alert generation metrics
|
||||
demo_alerts_generated_total = Counter(
|
||||
'demo_alerts_generated_total',
|
||||
'Total number of alerts generated post-clone',
|
||||
['tier', 'alert_type']
|
||||
)
|
||||
|
||||
demo_ai_insights_generated_total = Counter(
|
||||
'demo_ai_insights_generated_total',
|
||||
'Total number of AI insights generated post-clone',
|
||||
['tier', 'insight_type']
|
||||
)
|
||||
|
||||
# Cross-service metrics
|
||||
demo_cross_service_calls_total = Counter(
|
||||
'demo_cross_service_calls_total',
|
||||
'Total number of cross-service API calls during cloning',
|
||||
['source_service', 'target_service', 'status']
|
||||
)
|
||||
|
||||
demo_cross_service_call_duration_seconds = Histogram(
|
||||
'demo_cross_service_call_duration_seconds',
|
||||
'Duration of cross-service API calls during cloning',
|
||||
['source_service', 'target_service'],
|
||||
buckets=[0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 20, 30]
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Demo Session Services"""
|
||||
|
||||
from .session_manager import DemoSessionManager
|
||||
from .data_cloner import DemoDataCloner
|
||||
from .cleanup_service import DemoCleanupService
|
||||
|
||||
__all__ = ["DemoSessionManager", "DemoDataCloner", "DemoCleanupService"]
|
||||
__all__ = [
|
||||
"DemoSessionManager",
|
||||
"DemoCleanupService",
|
||||
]
|
||||
|
||||
@@ -4,14 +4,21 @@ Handles automatic cleanup of expired sessions
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import structlog
|
||||
import httpx
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from app.models import DemoSession, DemoSessionStatus
|
||||
from app.services.data_cloner import DemoDataCloner
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.core.redis_wrapper import DemoRedisWrapper
|
||||
from app.monitoring.metrics import (
|
||||
demo_sessions_deleted_total,
|
||||
demo_session_cleanup_duration_seconds,
|
||||
demo_sessions_active
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -22,7 +29,199 @@ class DemoCleanupService:
|
||||
def __init__(self, db: AsyncSession, redis: DemoRedisWrapper):
|
||||
self.db = db
|
||||
self.redis = redis
|
||||
self.data_cloner = DemoDataCloner(db, redis)
|
||||
from app.core.config import settings
|
||||
self.internal_api_key = settings.INTERNAL_API_KEY
|
||||
|
||||
# Service URLs for cleanup
|
||||
self.services = [
|
||||
("tenant", os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000")),
|
||||
("auth", os.getenv("AUTH_SERVICE_URL", "http://auth-service:8000")),
|
||||
("inventory", os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")),
|
||||
("recipes", os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000")),
|
||||
("suppliers", os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8000")),
|
||||
("production", os.getenv("PRODUCTION_SERVICE_URL", "http://production-service:8000")),
|
||||
("procurement", os.getenv("PROCUREMENT_SERVICE_URL", "http://procurement-service:8000")),
|
||||
("sales", os.getenv("SALES_SERVICE_URL", "http://sales-service:8000")),
|
||||
("orders", os.getenv("ORDERS_SERVICE_URL", "http://orders-service:8000")),
|
||||
("forecasting", os.getenv("FORECASTING_SERVICE_URL", "http://forecasting-service:8000")),
|
||||
("orchestrator", os.getenv("ORCHESTRATOR_SERVICE_URL", "http://orchestrator-service:8000")),
|
||||
]
|
||||
|
||||
async def cleanup_session(self, session: DemoSession) -> dict:
|
||||
"""
|
||||
Delete all data for a demo session across all services.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"total_deleted": int,
|
||||
"duration_ms": int,
|
||||
"details": {service: {records_deleted, duration_ms}},
|
||||
"errors": []
|
||||
}
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
virtual_tenant_id = str(session.virtual_tenant_id)
|
||||
session_id = session.session_id
|
||||
|
||||
logger.info(
|
||||
"Starting demo session cleanup",
|
||||
session_id=session_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
demo_account_type=session.demo_account_type
|
||||
)
|
||||
|
||||
# Delete from all services in parallel
|
||||
tasks = [
|
||||
self._delete_from_service(name, url, virtual_tenant_id)
|
||||
for name, url in self.services
|
||||
]
|
||||
|
||||
service_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Aggregate results
|
||||
total_deleted = 0
|
||||
details = {}
|
||||
errors = []
|
||||
|
||||
for (service_name, _), result in zip(self.services, service_results):
|
||||
if isinstance(result, Exception):
|
||||
errors.append(f"{service_name}: {str(result)}")
|
||||
details[service_name] = {"status": "error", "error": str(result)}
|
||||
else:
|
||||
total_deleted += result.get("records_deleted", {}).get("total", 0)
|
||||
details[service_name] = result
|
||||
|
||||
# Delete from Redis
|
||||
await self._delete_redis_cache(virtual_tenant_id)
|
||||
|
||||
# Delete child tenants if enterprise
|
||||
if session.demo_account_type == "enterprise":
|
||||
child_metadata = session.session_metadata.get("children", [])
|
||||
for child in child_metadata:
|
||||
child_tenant_id = child["virtual_tenant_id"]
|
||||
await self._delete_from_all_services(child_tenant_id)
|
||||
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
success = len(errors) == 0
|
||||
|
||||
logger.info(
|
||||
"Demo session cleanup completed",
|
||||
session_id=session_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
success=success,
|
||||
total_deleted=total_deleted,
|
||||
duration_ms=duration_ms,
|
||||
error_count=len(errors)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"total_deleted": total_deleted,
|
||||
"duration_ms": duration_ms,
|
||||
"details": details,
|
||||
"errors": errors
|
||||
}
|
||||
|
||||
async def _delete_from_service(
|
||||
self,
|
||||
service_name: str,
|
||||
service_url: str,
|
||||
virtual_tenant_id: str
|
||||
) -> dict:
|
||||
"""Delete all data from a single service"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
f"{service_url}/internal/demo/tenant/{virtual_tenant_id}",
|
||||
headers={"X-Internal-API-Key": self.internal_api_key}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 404:
|
||||
# Already deleted or never existed - idempotent
|
||||
return {
|
||||
"service": service_name,
|
||||
"status": "not_found",
|
||||
"records_deleted": {"total": 0}
|
||||
}
|
||||
else:
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete from service",
|
||||
service=service_name,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
async def _delete_redis_cache(self, virtual_tenant_id: str):
|
||||
"""Delete all Redis keys for a virtual tenant"""
|
||||
try:
|
||||
client = await self.redis.get_client()
|
||||
pattern = f"*:{virtual_tenant_id}:*"
|
||||
keys = await client.keys(pattern)
|
||||
if keys:
|
||||
await client.delete(*keys)
|
||||
logger.debug("Deleted Redis cache", tenant_id=virtual_tenant_id, keys_deleted=len(keys))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to delete Redis cache", error=str(e), tenant_id=virtual_tenant_id)
|
||||
|
||||
async def _delete_from_all_services(self, virtual_tenant_id: str):
|
||||
"""Delete data from all services for a tenant"""
|
||||
tasks = [
|
||||
self._delete_from_service(name, url, virtual_tenant_id)
|
||||
for name, url in self.services
|
||||
]
|
||||
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def _delete_tenant_data(self, tenant_id: str, session_id: str) -> dict:
|
||||
"""Delete demo data for a tenant across all services"""
|
||||
logger.info("Deleting tenant data", tenant_id=tenant_id, session_id=session_id)
|
||||
|
||||
results = {}
|
||||
|
||||
async def delete_from_service(service_name: str, service_url: str):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
f"{service_url}/internal/demo/tenant/{tenant_id}",
|
||||
headers={"X-Internal-API-Key": self.internal_api_key}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.debug(f"Deleted data from {service_name}", tenant_id=tenant_id)
|
||||
return {"service": service_name, "status": "deleted"}
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete from {service_name}",
|
||||
status_code=response.status_code,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return {"service": service_name, "status": "failed", "error": f"HTTP {response.status_code}"}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Exception deleting from {service_name}",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
return {"service": service_name, "status": "failed", "error": str(e)}
|
||||
|
||||
# Delete from all services in parallel
|
||||
tasks = [delete_from_service(name, url) for name, url in self.services]
|
||||
service_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in service_results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error("Service deletion failed", error=str(result))
|
||||
elif isinstance(result, dict):
|
||||
results[result["service"]] = result
|
||||
|
||||
return results
|
||||
|
||||
async def cleanup_expired_sessions(self) -> dict:
|
||||
"""
|
||||
@@ -32,10 +231,10 @@ class DemoCleanupService:
|
||||
Returns:
|
||||
Cleanup statistics
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
logger.info("Starting demo session cleanup")
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
stuck_threshold = now - timedelta(minutes=5) # Sessions pending > 5 min are stuck
|
||||
|
||||
@@ -97,10 +296,7 @@ class DemoCleanupService:
|
||||
)
|
||||
for child_id in child_tenant_ids:
|
||||
try:
|
||||
await self.data_cloner.delete_session_data(
|
||||
str(child_id),
|
||||
session.session_id
|
||||
)
|
||||
await self._delete_tenant_data(child_id, session.session_id)
|
||||
except Exception as child_error:
|
||||
logger.error(
|
||||
"Failed to delete child tenant",
|
||||
@@ -109,11 +305,14 @@ class DemoCleanupService:
|
||||
)
|
||||
|
||||
# Delete parent/main session data
|
||||
await self.data_cloner.delete_session_data(
|
||||
await self._delete_tenant_data(
|
||||
str(session.virtual_tenant_id),
|
||||
session.session_id
|
||||
)
|
||||
|
||||
# Delete Redis data
|
||||
await self.redis.delete_session_data(session.session_id)
|
||||
|
||||
stats["cleaned_up"] += 1
|
||||
|
||||
logger.info(
|
||||
@@ -137,6 +336,19 @@ class DemoCleanupService:
|
||||
)
|
||||
|
||||
logger.info("Demo session cleanup completed", stats=stats)
|
||||
|
||||
# Update Prometheus metrics
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
demo_session_cleanup_duration_seconds.labels(tier="all").observe(duration_ms / 1000)
|
||||
|
||||
# Update deleted sessions metrics by tier (we need to determine tiers from sessions)
|
||||
for session in all_sessions_to_cleanup:
|
||||
demo_sessions_deleted_total.labels(
|
||||
tier=session.demo_account_type,
|
||||
status="success"
|
||||
).inc()
|
||||
demo_sessions_active.labels(tier=session.demo_account_type).dec()
|
||||
|
||||
return stats
|
||||
|
||||
async def cleanup_old_destroyed_sessions(self, days: int = 7) -> int:
|
||||
@@ -149,8 +361,6 @@ class DemoCleanupService:
|
||||
Returns:
|
||||
Number of deleted records
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
result = await self.db.execute(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,604 +0,0 @@
|
||||
"""
|
||||
Cloning Strategy Pattern Implementation
|
||||
Provides explicit, type-safe strategies for different demo account types
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CloningContext:
|
||||
"""
|
||||
Context object containing all data needed for cloning operations
|
||||
Immutable to prevent state mutation bugs
|
||||
"""
|
||||
base_tenant_id: str
|
||||
virtual_tenant_id: str
|
||||
session_id: str
|
||||
demo_account_type: str
|
||||
session_metadata: Optional[Dict[str, Any]] = None
|
||||
services_filter: Optional[List[str]] = None
|
||||
|
||||
# Orchestrator dependencies (injected)
|
||||
orchestrator: Any = None # Will be CloneOrchestrator instance
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate context after initialization"""
|
||||
if not self.base_tenant_id:
|
||||
raise ValueError("base_tenant_id is required")
|
||||
if not self.virtual_tenant_id:
|
||||
raise ValueError("virtual_tenant_id is required")
|
||||
if not self.session_id:
|
||||
raise ValueError("session_id is required")
|
||||
|
||||
|
||||
class CloningStrategy(ABC):
|
||||
"""
|
||||
Abstract base class for cloning strategies
|
||||
Each strategy is a leaf node - no recursion possible
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def clone(self, context: CloningContext) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute the cloning strategy
|
||||
|
||||
Args:
|
||||
context: Immutable context with all required data
|
||||
|
||||
Returns:
|
||||
Dictionary with cloning results
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_strategy_name(self) -> str:
|
||||
"""Return the name of this strategy for logging"""
|
||||
pass
|
||||
|
||||
|
||||
class ProfessionalCloningStrategy(CloningStrategy):
|
||||
"""
|
||||
Strategy for single-tenant professional demos
|
||||
Clones all services for a single virtual tenant
|
||||
"""
|
||||
|
||||
def get_strategy_name(self) -> str:
|
||||
return "professional"
|
||||
|
||||
async def clone(self, context: CloningContext) -> Dict[str, Any]:
|
||||
"""
|
||||
Clone demo data for a professional (single-tenant) account
|
||||
|
||||
Process:
|
||||
1. Validate context
|
||||
2. Clone all services in parallel
|
||||
3. Handle failures with partial success support
|
||||
4. Return aggregated results
|
||||
"""
|
||||
logger.info(
|
||||
"Executing professional cloning strategy",
|
||||
session_id=context.session_id,
|
||||
virtual_tenant_id=context.virtual_tenant_id,
|
||||
base_tenant_id=context.base_tenant_id
|
||||
)
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Determine which services to clone
|
||||
services_to_clone = context.orchestrator.services
|
||||
if context.services_filter:
|
||||
services_to_clone = [
|
||||
s for s in context.orchestrator.services
|
||||
if s.name in context.services_filter
|
||||
]
|
||||
logger.info(
|
||||
"Filtering services",
|
||||
session_id=context.session_id,
|
||||
services_filter=context.services_filter,
|
||||
filtered_count=len(services_to_clone)
|
||||
)
|
||||
|
||||
# Rollback stack for cleanup
|
||||
rollback_stack = []
|
||||
|
||||
try:
|
||||
# Import asyncio here to avoid circular imports
|
||||
import asyncio
|
||||
|
||||
# Create parallel tasks for all services
|
||||
tasks = []
|
||||
service_map = {}
|
||||
|
||||
for service_def in services_to_clone:
|
||||
task = asyncio.create_task(
|
||||
context.orchestrator._clone_service(
|
||||
service_def=service_def,
|
||||
base_tenant_id=context.base_tenant_id,
|
||||
virtual_tenant_id=context.virtual_tenant_id,
|
||||
demo_account_type=context.demo_account_type,
|
||||
session_id=context.session_id,
|
||||
session_metadata=context.session_metadata
|
||||
)
|
||||
)
|
||||
tasks.append(task)
|
||||
service_map[task] = service_def.name
|
||||
|
||||
# Process tasks as they complete for real-time progress updates
|
||||
service_results = {}
|
||||
total_records = 0
|
||||
failed_services = []
|
||||
required_service_failed = False
|
||||
completed_count = 0
|
||||
total_count = len(tasks)
|
||||
|
||||
# Create a mapping from futures to service names to properly identify completed tasks
|
||||
# We'll use asyncio.wait approach instead of as_completed to access the original tasks
|
||||
pending = set(tasks)
|
||||
completed_tasks_info = {task: service_map[task] for task in tasks} # Map tasks to service names
|
||||
|
||||
while pending:
|
||||
# Wait for at least one task to complete
|
||||
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
# Process each completed task
|
||||
for completed_task in done:
|
||||
try:
|
||||
# Get the result from the completed task
|
||||
result = await completed_task
|
||||
# Get the service name from our mapping
|
||||
service_name = completed_tasks_info[completed_task]
|
||||
service_def = next(s for s in services_to_clone if s.name == service_name)
|
||||
|
||||
service_results[service_name] = result
|
||||
completed_count += 1
|
||||
|
||||
if result.get("status") == "failed":
|
||||
failed_services.append(service_name)
|
||||
if service_def.required:
|
||||
required_service_failed = True
|
||||
else:
|
||||
total_records += result.get("records_cloned", 0)
|
||||
|
||||
# Track successful services for rollback
|
||||
if result.get("status") == "completed":
|
||||
rollback_stack.append({
|
||||
"type": "service",
|
||||
"service_name": service_name,
|
||||
"tenant_id": context.virtual_tenant_id,
|
||||
"session_id": context.session_id
|
||||
})
|
||||
|
||||
# Update Redis with granular progress after each service completes
|
||||
await context.orchestrator._update_progress_in_redis(context.session_id, {
|
||||
"completed_services": completed_count,
|
||||
"total_services": total_count,
|
||||
"progress_percentage": int((completed_count / total_count) * 100),
|
||||
"services": service_results,
|
||||
"total_records_cloned": total_records
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"Service {service_name} completed ({completed_count}/{total_count})",
|
||||
session_id=context.session_id,
|
||||
records_cloned=result.get("records_cloned", 0)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Handle exceptions from the task itself
|
||||
service_name = completed_tasks_info[completed_task]
|
||||
service_def = next(s for s in services_to_clone if s.name == service_name)
|
||||
|
||||
logger.error(
|
||||
f"Service {service_name} cloning failed with exception",
|
||||
session_id=context.session_id,
|
||||
error=str(e)
|
||||
)
|
||||
service_results[service_name] = {
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"records_cloned": 0
|
||||
}
|
||||
failed_services.append(service_name)
|
||||
completed_count += 1
|
||||
if service_def.required:
|
||||
required_service_failed = True
|
||||
|
||||
# Determine overall status
|
||||
if required_service_failed:
|
||||
overall_status = "failed"
|
||||
elif failed_services:
|
||||
overall_status = "partial"
|
||||
else:
|
||||
overall_status = "completed"
|
||||
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Professional cloning strategy completed",
|
||||
session_id=context.session_id,
|
||||
overall_status=overall_status,
|
||||
total_records=total_records,
|
||||
failed_services=failed_services,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
return {
|
||||
"overall_status": overall_status,
|
||||
"services": service_results,
|
||||
"total_records": total_records,
|
||||
"failed_services": failed_services,
|
||||
"duration_ms": duration_ms,
|
||||
"rollback_stack": rollback_stack
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Professional cloning strategy failed",
|
||||
session_id=context.session_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
"overall_status": "failed",
|
||||
"error": str(e),
|
||||
"services": {},
|
||||
"total_records": 0,
|
||||
"failed_services": [],
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
||||
"rollback_stack": rollback_stack
|
||||
}
|
||||
|
||||
|
||||
class EnterpriseCloningStrategy(CloningStrategy):
|
||||
"""
|
||||
Strategy for multi-tenant enterprise demos
|
||||
Clones parent tenant + child tenants + distribution data
|
||||
"""
|
||||
|
||||
def get_strategy_name(self) -> str:
|
||||
return "enterprise"
|
||||
|
||||
async def clone(self, context: CloningContext) -> Dict[str, Any]:
|
||||
"""
|
||||
Clone demo data for an enterprise (multi-tenant) account
|
||||
|
||||
Process:
|
||||
1. Validate enterprise metadata
|
||||
2. Clone parent tenant using ProfessionalCloningStrategy
|
||||
3. Clone child tenants in parallel
|
||||
4. Update distribution data with child mappings
|
||||
5. Return aggregated results
|
||||
|
||||
NOTE: No recursion - uses ProfessionalCloningStrategy as a helper
|
||||
"""
|
||||
logger.info(
|
||||
"Executing enterprise cloning strategy",
|
||||
session_id=context.session_id,
|
||||
parent_tenant_id=context.virtual_tenant_id,
|
||||
base_tenant_id=context.base_tenant_id
|
||||
)
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
results = {
|
||||
"parent": {},
|
||||
"children": [],
|
||||
"distribution": {},
|
||||
"overall_status": "pending"
|
||||
}
|
||||
rollback_stack = []
|
||||
|
||||
try:
|
||||
# Validate enterprise metadata
|
||||
if not context.session_metadata:
|
||||
raise ValueError("Enterprise cloning requires session_metadata")
|
||||
|
||||
is_enterprise = context.session_metadata.get("is_enterprise", False)
|
||||
child_configs = context.session_metadata.get("child_configs", [])
|
||||
child_tenant_ids = context.session_metadata.get("child_tenant_ids", [])
|
||||
|
||||
if not is_enterprise:
|
||||
raise ValueError("session_metadata.is_enterprise must be True")
|
||||
|
||||
if not child_configs or not child_tenant_ids:
|
||||
raise ValueError("Enterprise metadata missing child_configs or child_tenant_ids")
|
||||
|
||||
logger.info(
|
||||
"Enterprise metadata validated",
|
||||
session_id=context.session_id,
|
||||
child_count=len(child_configs)
|
||||
)
|
||||
|
||||
# Phase 1: Clone parent tenant
|
||||
logger.info("Phase 1: Cloning parent tenant", session_id=context.session_id)
|
||||
|
||||
# Update progress
|
||||
await context.orchestrator._update_progress_in_redis(context.session_id, {
|
||||
"parent": {"overall_status": "pending"},
|
||||
"children": [],
|
||||
"distribution": {}
|
||||
})
|
||||
|
||||
# Use ProfessionalCloningStrategy to clone parent
|
||||
# This is composition, not recursion - explicit strategy usage
|
||||
professional_strategy = ProfessionalCloningStrategy()
|
||||
parent_context = CloningContext(
|
||||
base_tenant_id=context.base_tenant_id,
|
||||
virtual_tenant_id=context.virtual_tenant_id,
|
||||
session_id=context.session_id,
|
||||
demo_account_type="enterprise", # Explicit type for parent tenant
|
||||
session_metadata=context.session_metadata,
|
||||
orchestrator=context.orchestrator
|
||||
)
|
||||
|
||||
parent_result = await professional_strategy.clone(parent_context)
|
||||
results["parent"] = parent_result
|
||||
|
||||
# Update progress
|
||||
await context.orchestrator._update_progress_in_redis(context.session_id, {
|
||||
"parent": parent_result,
|
||||
"children": [],
|
||||
"distribution": {}
|
||||
})
|
||||
|
||||
# Track parent for rollback
|
||||
if parent_result.get("overall_status") not in ["failed"]:
|
||||
rollback_stack.append({
|
||||
"type": "tenant",
|
||||
"tenant_id": context.virtual_tenant_id,
|
||||
"session_id": context.session_id
|
||||
})
|
||||
|
||||
# Validate parent success
|
||||
parent_status = parent_result.get("overall_status")
|
||||
|
||||
if parent_status == "failed":
|
||||
logger.error(
|
||||
"Parent cloning failed, aborting enterprise demo",
|
||||
session_id=context.session_id,
|
||||
failed_services=parent_result.get("failed_services", [])
|
||||
)
|
||||
results["overall_status"] = "failed"
|
||||
results["error"] = "Parent tenant cloning failed"
|
||||
results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
return results
|
||||
|
||||
if parent_status == "partial":
|
||||
# Check if tenant service succeeded (critical)
|
||||
parent_services = parent_result.get("services", {})
|
||||
if parent_services.get("tenant", {}).get("status") != "completed":
|
||||
logger.error(
|
||||
"Tenant service failed in parent, cannot create children",
|
||||
session_id=context.session_id
|
||||
)
|
||||
results["overall_status"] = "failed"
|
||||
results["error"] = "Parent tenant creation failed - cannot create child tenants"
|
||||
results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
return results
|
||||
|
||||
logger.info(
|
||||
"Parent cloning succeeded, proceeding with children",
|
||||
session_id=context.session_id,
|
||||
parent_status=parent_status
|
||||
)
|
||||
|
||||
# Phase 2: Clone child tenants in parallel
|
||||
logger.info(
|
||||
"Phase 2: Cloning child outlets",
|
||||
session_id=context.session_id,
|
||||
child_count=len(child_configs)
|
||||
)
|
||||
|
||||
# Update progress
|
||||
await context.orchestrator._update_progress_in_redis(context.session_id, {
|
||||
"parent": parent_result,
|
||||
"children": [{"status": "pending"} for _ in child_configs],
|
||||
"distribution": {}
|
||||
})
|
||||
|
||||
# Import asyncio for parallel execution
|
||||
import asyncio
|
||||
|
||||
child_tasks = []
|
||||
for idx, (child_config, child_id) in enumerate(zip(child_configs, child_tenant_ids)):
|
||||
task = context.orchestrator._clone_child_outlet(
|
||||
base_tenant_id=child_config.get("base_tenant_id"),
|
||||
virtual_child_id=child_id,
|
||||
parent_tenant_id=context.virtual_tenant_id,
|
||||
child_name=child_config.get("name"),
|
||||
location=child_config.get("location"),
|
||||
session_id=context.session_id
|
||||
)
|
||||
child_tasks.append(task)
|
||||
|
||||
child_results = await asyncio.gather(*child_tasks, return_exceptions=True)
|
||||
|
||||
# Process child results
|
||||
children_data = []
|
||||
failed_children = 0
|
||||
|
||||
for idx, result in enumerate(child_results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
f"Child {idx} cloning failed",
|
||||
session_id=context.session_id,
|
||||
error=str(result)
|
||||
)
|
||||
children_data.append({
|
||||
"status": "failed",
|
||||
"error": str(result),
|
||||
"child_id": child_tenant_ids[idx] if idx < len(child_tenant_ids) else None
|
||||
})
|
||||
failed_children += 1
|
||||
else:
|
||||
children_data.append(result)
|
||||
if result.get("overall_status") == "failed":
|
||||
failed_children += 1
|
||||
else:
|
||||
# Track for rollback
|
||||
rollback_stack.append({
|
||||
"type": "tenant",
|
||||
"tenant_id": result.get("child_id"),
|
||||
"session_id": context.session_id
|
||||
})
|
||||
|
||||
results["children"] = children_data
|
||||
|
||||
# Update progress
|
||||
await context.orchestrator._update_progress_in_redis(context.session_id, {
|
||||
"parent": parent_result,
|
||||
"children": children_data,
|
||||
"distribution": {}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Child cloning completed",
|
||||
session_id=context.session_id,
|
||||
total_children=len(child_configs),
|
||||
failed_children=failed_children
|
||||
)
|
||||
|
||||
# Phase 3: Clone distribution data
|
||||
logger.info("Phase 3: Cloning distribution data", session_id=context.session_id)
|
||||
|
||||
# Find distribution service definition
|
||||
dist_service_def = next(
|
||||
(s for s in context.orchestrator.services if s.name == "distribution"),
|
||||
None
|
||||
)
|
||||
|
||||
if dist_service_def:
|
||||
dist_result = await context.orchestrator._clone_service(
|
||||
service_def=dist_service_def,
|
||||
base_tenant_id=context.base_tenant_id,
|
||||
virtual_tenant_id=context.virtual_tenant_id,
|
||||
demo_account_type="enterprise",
|
||||
session_id=context.session_id,
|
||||
session_metadata=context.session_metadata
|
||||
)
|
||||
results["distribution"] = dist_result
|
||||
|
||||
# Update progress
|
||||
await context.orchestrator._update_progress_in_redis(context.session_id, {
|
||||
"parent": parent_result,
|
||||
"children": children_data,
|
||||
"distribution": dist_result
|
||||
})
|
||||
|
||||
# Track for rollback
|
||||
if dist_result.get("status") == "completed":
|
||||
rollback_stack.append({
|
||||
"type": "service",
|
||||
"service_name": "distribution",
|
||||
"tenant_id": context.virtual_tenant_id,
|
||||
"session_id": context.session_id
|
||||
})
|
||||
total_records_cloned = parent_result.get("total_records", 0)
|
||||
total_records_cloned += dist_result.get("records_cloned", 0)
|
||||
else:
|
||||
logger.warning("Distribution service not found in orchestrator", session_id=context.session_id)
|
||||
|
||||
# Determine overall status
|
||||
if failed_children == len(child_configs):
|
||||
overall_status = "failed"
|
||||
elif failed_children > 0:
|
||||
overall_status = "partial"
|
||||
else:
|
||||
overall_status = "completed" # Changed from "ready" to match professional strategy
|
||||
|
||||
# Calculate total records cloned (parent + all children)
|
||||
total_records_cloned = parent_result.get("total_records", 0)
|
||||
for child in children_data:
|
||||
if isinstance(child, dict):
|
||||
total_records_cloned += child.get("total_records", child.get("records_cloned", 0))
|
||||
|
||||
results["overall_status"] = overall_status
|
||||
results["total_records_cloned"] = total_records_cloned # Add for session manager
|
||||
results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
results["rollback_stack"] = rollback_stack
|
||||
|
||||
# Include services from parent for session manager compatibility
|
||||
results["services"] = parent_result.get("services", {})
|
||||
|
||||
logger.info(
|
||||
"Enterprise cloning strategy completed",
|
||||
session_id=context.session_id,
|
||||
overall_status=overall_status,
|
||||
parent_status=parent_status,
|
||||
children_status=f"{len(child_configs) - failed_children}/{len(child_configs)} succeeded",
|
||||
total_records_cloned=total_records_cloned,
|
||||
duration_ms=results["duration_ms"]
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Enterprise cloning strategy failed",
|
||||
session_id=context.session_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return {
|
||||
"overall_status": "failed",
|
||||
"error": str(e),
|
||||
"parent": {},
|
||||
"children": [],
|
||||
"distribution": {},
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
||||
"rollback_stack": rollback_stack
|
||||
}
|
||||
|
||||
class CloningStrategyFactory:
|
||||
"""
|
||||
Factory for creating cloning strategies
|
||||
Provides type-safe strategy selection
|
||||
"""
|
||||
|
||||
_strategies: Dict[str, CloningStrategy] = {
|
||||
"professional": ProfessionalCloningStrategy(),
|
||||
"enterprise": EnterpriseCloningStrategy(),
|
||||
"enterprise_child": ProfessionalCloningStrategy() # Alias: children use professional strategy
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_strategy(cls, demo_account_type: str) -> CloningStrategy:
|
||||
"""
|
||||
Get the appropriate cloning strategy for the demo account type
|
||||
|
||||
Args:
|
||||
demo_account_type: Type of demo account ("professional" or "enterprise")
|
||||
|
||||
Returns:
|
||||
CloningStrategy instance
|
||||
|
||||
Raises:
|
||||
ValueError: If demo_account_type is not supported
|
||||
"""
|
||||
strategy = cls._strategies.get(demo_account_type)
|
||||
|
||||
if not strategy:
|
||||
raise ValueError(
|
||||
f"Unknown demo_account_type: {demo_account_type}. "
|
||||
f"Supported types: {list(cls._strategies.keys())}"
|
||||
)
|
||||
|
||||
return strategy
|
||||
|
||||
@classmethod
|
||||
def register_strategy(cls, name: str, strategy: CloningStrategy):
|
||||
"""
|
||||
Register a custom cloning strategy
|
||||
|
||||
Args:
|
||||
name: Strategy name
|
||||
strategy: Strategy instance
|
||||
"""
|
||||
cls._strategies[name] = strategy
|
||||
logger.info(f"Registered custom cloning strategy: {name}")
|
||||
@@ -1,356 +0,0 @@
|
||||
"""
|
||||
Demo Data Cloner
|
||||
Clones base demo data to session-specific virtual tenants
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Dict, Any, List, Optional
|
||||
import httpx
|
||||
import structlog
|
||||
import uuid
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
from app.core.redis_wrapper import DemoRedisWrapper
|
||||
from app.core import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class DemoDataCloner:
|
||||
"""Clones demo data for isolated sessions"""
|
||||
|
||||
def __init__(self, db: AsyncSession, redis: DemoRedisWrapper):
|
||||
self.db = db
|
||||
self.redis = redis
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create shared HTTP client with connection pooling"""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0, connect_timeout=10.0),
|
||||
limits=httpx.Limits(
|
||||
max_connections=20,
|
||||
max_keepalive_connections=10,
|
||||
keepalive_expiry=30.0
|
||||
)
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client on cleanup"""
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
async def clone_tenant_data(
|
||||
self,
|
||||
session_id: str,
|
||||
base_demo_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
demo_account_type: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Clone all demo data from base tenant to virtual tenant
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
base_demo_tenant_id: Base demo tenant UUID
|
||||
virtual_tenant_id: Virtual tenant UUID for this session
|
||||
demo_account_type: Type of demo account
|
||||
|
||||
Returns:
|
||||
Cloning statistics
|
||||
"""
|
||||
logger.info(
|
||||
"Starting data cloning",
|
||||
session_id=session_id,
|
||||
base_demo_tenant_id=base_demo_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
stats = {
|
||||
"session_id": session_id,
|
||||
"services_cloned": [],
|
||||
"total_records": 0,
|
||||
"redis_keys": 0
|
||||
}
|
||||
|
||||
# Clone data from each service based on demo account type
|
||||
services_to_clone = self._get_services_for_demo_type(demo_account_type)
|
||||
|
||||
for service_name in services_to_clone:
|
||||
try:
|
||||
service_stats = await self._clone_service_data(
|
||||
service_name,
|
||||
base_demo_tenant_id,
|
||||
virtual_tenant_id,
|
||||
session_id,
|
||||
demo_account_type
|
||||
)
|
||||
stats["services_cloned"].append(service_name)
|
||||
stats["total_records"] += service_stats.get("records_cloned", 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to clone service data",
|
||||
service=service_name,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Populate Redis cache with hot data
|
||||
redis_stats = await self._populate_redis_cache(
|
||||
session_id,
|
||||
virtual_tenant_id,
|
||||
demo_account_type
|
||||
)
|
||||
stats["redis_keys"] = redis_stats.get("keys_created", 0)
|
||||
|
||||
logger.info(
|
||||
"Data cloning completed",
|
||||
session_id=session_id,
|
||||
stats=stats
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
def _get_services_for_demo_type(self, demo_account_type: str) -> List[str]:
|
||||
"""Get list of services to clone based on demo type"""
|
||||
base_services = ["inventory", "sales", "orders", "pos"]
|
||||
|
||||
if demo_account_type == "professional":
|
||||
# Professional has production, recipes, suppliers, and procurement
|
||||
return base_services + ["recipes", "production", "suppliers", "procurement", "alert_processor"]
|
||||
elif demo_account_type == "enterprise":
|
||||
# Enterprise has suppliers, procurement, and distribution (for parent-child network)
|
||||
return base_services + ["suppliers", "procurement", "distribution", "alert_processor"]
|
||||
else:
|
||||
# Basic tenant has suppliers and procurement
|
||||
return base_services + ["suppliers", "procurement", "distribution", "alert_processor"]
|
||||
|
||||
async def _clone_service_data(
|
||||
self,
|
||||
service_name: str,
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
session_id: str,
|
||||
demo_account_type: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Clone data for a specific service
|
||||
|
||||
Args:
|
||||
service_name: Name of the service
|
||||
base_tenant_id: Source tenant ID
|
||||
virtual_tenant_id: Target tenant ID
|
||||
session_id: Session ID
|
||||
demo_account_type: Type of demo account
|
||||
|
||||
Returns:
|
||||
Cloning statistics
|
||||
"""
|
||||
service_url = self._get_service_url(service_name)
|
||||
|
||||
# Get internal API key from settings
|
||||
from app.core.config import settings
|
||||
internal_api_key = settings.INTERNAL_API_KEY
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{service_url}/internal/demo/clone",
|
||||
json={
|
||||
"base_tenant_id": base_tenant_id,
|
||||
"virtual_tenant_id": virtual_tenant_id,
|
||||
"session_id": session_id,
|
||||
"demo_account_type": demo_account_type
|
||||
},
|
||||
headers={"X-Internal-API-Key": internal_api_key}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _populate_redis_cache(
|
||||
self,
|
||||
session_id: str,
|
||||
virtual_tenant_id: str,
|
||||
demo_account_type: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Populate Redis with frequently accessed data
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
virtual_tenant_id: Virtual tenant ID
|
||||
demo_account_type: Demo account type
|
||||
|
||||
Returns:
|
||||
Statistics about cached data
|
||||
"""
|
||||
logger.info("Populating Redis cache", session_id=session_id)
|
||||
|
||||
keys_created = 0
|
||||
|
||||
# Cache inventory data (hot data)
|
||||
try:
|
||||
inventory_data = await self._fetch_inventory_data(virtual_tenant_id)
|
||||
await self.redis.set_session_data(
|
||||
session_id,
|
||||
"inventory",
|
||||
inventory_data,
|
||||
ttl=settings.REDIS_SESSION_TTL
|
||||
)
|
||||
keys_created += 1
|
||||
except Exception as e:
|
||||
logger.error("Failed to cache inventory", error=str(e))
|
||||
|
||||
# Cache POS data
|
||||
try:
|
||||
pos_data = await self._fetch_pos_data(virtual_tenant_id)
|
||||
await self.redis.set_session_data(
|
||||
session_id,
|
||||
"pos",
|
||||
pos_data,
|
||||
ttl=settings.REDIS_SESSION_TTL
|
||||
)
|
||||
keys_created += 1
|
||||
except Exception as e:
|
||||
logger.error("Failed to cache POS data", error=str(e))
|
||||
|
||||
# Cache recent sales
|
||||
try:
|
||||
sales_data = await self._fetch_recent_sales(virtual_tenant_id)
|
||||
await self.redis.set_session_data(
|
||||
session_id,
|
||||
"recent_sales",
|
||||
sales_data,
|
||||
ttl=settings.REDIS_SESSION_TTL
|
||||
)
|
||||
keys_created += 1
|
||||
except Exception as e:
|
||||
logger.error("Failed to cache sales", error=str(e))
|
||||
|
||||
return {"keys_created": keys_created}
|
||||
|
||||
async def _fetch_inventory_data(self, tenant_id: str) -> Dict[str, Any]:
|
||||
"""Fetch inventory data for caching"""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect_timeout=5.0)) as client:
|
||||
response = await client.get(
|
||||
f"{settings.INVENTORY_SERVICE_URL}/api/inventory/summary",
|
||||
headers={"X-Tenant-Id": tenant_id}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def _fetch_pos_data(self, tenant_id: str) -> Dict[str, Any]:
|
||||
"""Fetch POS data for caching"""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect_timeout=5.0)) as client:
|
||||
response = await client.get(
|
||||
f"{settings.POS_SERVICE_URL}/api/pos/current-session",
|
||||
headers={"X-Tenant-Id": tenant_id}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def _fetch_recent_sales(self, tenant_id: str) -> Dict[str, Any]:
|
||||
"""Fetch recent sales for caching"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{settings.SALES_SERVICE_URL}/api/sales/recent?limit=50",
|
||||
headers={"X-Tenant-Id": tenant_id}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def _get_service_url(self, service_name: str) -> str:
|
||||
"""Get service URL from settings"""
|
||||
url_map = {
|
||||
"inventory": settings.INVENTORY_SERVICE_URL,
|
||||
"recipes": settings.RECIPES_SERVICE_URL,
|
||||
"sales": settings.SALES_SERVICE_URL,
|
||||
"orders": settings.ORDERS_SERVICE_URL,
|
||||
"production": settings.PRODUCTION_SERVICE_URL,
|
||||
"suppliers": settings.SUPPLIERS_SERVICE_URL,
|
||||
"pos": settings.POS_SERVICE_URL,
|
||||
"procurement": settings.PROCUREMENT_SERVICE_URL,
|
||||
"distribution": settings.DISTRIBUTION_SERVICE_URL,
|
||||
"forecasting": settings.FORECASTING_SERVICE_URL,
|
||||
"alert_processor": settings.ALERT_PROCESSOR_SERVICE_URL,
|
||||
}
|
||||
return url_map.get(service_name, "")
|
||||
|
||||
async def delete_session_data(
|
||||
self,
|
||||
virtual_tenant_id: str,
|
||||
session_id: str
|
||||
):
|
||||
"""
|
||||
Delete all data for a session using parallel deletion for performance
|
||||
|
||||
Args:
|
||||
virtual_tenant_id: Virtual tenant ID to delete
|
||||
session_id: Session ID
|
||||
"""
|
||||
logger.info(
|
||||
"Deleting session data",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
# Get shared HTTP client for all deletions
|
||||
client = await self.get_http_client()
|
||||
|
||||
# Services list - all can be deleted in parallel as deletion endpoints
|
||||
# handle their own internal ordering if needed
|
||||
services = [
|
||||
"forecasting",
|
||||
"sales",
|
||||
"orders",
|
||||
"production",
|
||||
"inventory",
|
||||
"recipes",
|
||||
"suppliers",
|
||||
"pos",
|
||||
"distribution",
|
||||
"procurement",
|
||||
"alert_processor"
|
||||
]
|
||||
|
||||
# Create deletion tasks for all services
|
||||
deletion_tasks = [
|
||||
self._delete_service_data(service_name, virtual_tenant_id, client)
|
||||
for service_name in services
|
||||
]
|
||||
|
||||
# Execute all deletions in parallel with exception handling
|
||||
results = await asyncio.gather(*deletion_tasks, return_exceptions=True)
|
||||
|
||||
# Log any failures
|
||||
for service_name, result in zip(services, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Failed to delete service data",
|
||||
service=service_name,
|
||||
error=str(result)
|
||||
)
|
||||
|
||||
# Delete from Redis
|
||||
await self.redis.delete_session_data(session_id)
|
||||
|
||||
logger.info("Session data deleted", virtual_tenant_id=virtual_tenant_id)
|
||||
|
||||
async def _delete_service_data(
|
||||
self,
|
||||
service_name: str,
|
||||
virtual_tenant_id: str,
|
||||
client: httpx.AsyncClient
|
||||
):
|
||||
"""Delete data from a specific service using provided HTTP client"""
|
||||
service_url = self._get_service_url(service_name)
|
||||
|
||||
# Get internal API key from settings
|
||||
from app.core.config import settings
|
||||
internal_api_key = settings.INTERNAL_API_KEY
|
||||
|
||||
await client.delete(
|
||||
f"{service_url}/internal/demo/tenant/{virtual_tenant_id}",
|
||||
headers={"X-Internal-API-Key": internal_api_key}
|
||||
)
|
||||
@@ -75,18 +75,11 @@ class DemoSessionManager:
|
||||
|
||||
base_tenant_id = uuid.UUID(base_tenant_id_str)
|
||||
|
||||
# Validate that the base tenant ID exists in the tenant service
|
||||
# This is important to prevent cloning from non-existent base tenants
|
||||
await self._validate_base_tenant_exists(base_tenant_id, demo_account_type)
|
||||
|
||||
# Handle enterprise chain setup
|
||||
child_tenant_ids = []
|
||||
if demo_account_type == 'enterprise':
|
||||
# Validate child template tenants exist before proceeding
|
||||
child_configs = demo_config.get('children', [])
|
||||
await self._validate_child_template_tenants(child_configs)
|
||||
|
||||
# Generate child tenant IDs for enterprise demos
|
||||
child_configs = demo_config.get('children', [])
|
||||
child_tenant_ids = [uuid.uuid4() for _ in child_configs]
|
||||
|
||||
# Create session record using repository
|
||||
@@ -208,9 +201,7 @@ class DemoSessionManager:
|
||||
async def destroy_session(self, session_id: str):
|
||||
"""
|
||||
Destroy a demo session and cleanup resources
|
||||
|
||||
Args:
|
||||
session_id: Session ID to destroy
|
||||
This triggers parallel deletion across all services.
|
||||
"""
|
||||
session = await self.get_session(session_id)
|
||||
|
||||
@@ -218,8 +209,30 @@ class DemoSessionManager:
|
||||
logger.warning("Session not found for destruction", session_id=session_id)
|
||||
return
|
||||
|
||||
# Update session status via repository
|
||||
await self.repository.destroy(session_id)
|
||||
# Update status to DESTROYING
|
||||
await self.repository.update_fields(
|
||||
session_id,
|
||||
status=DemoSessionStatus.DESTROYING
|
||||
)
|
||||
|
||||
# Trigger cleanup across all services
|
||||
cleanup_service = DemoCleanupService(self.db, self.redis)
|
||||
result = await cleanup_service.cleanup_session(session)
|
||||
|
||||
if result["success"]:
|
||||
# Update status to DESTROYED
|
||||
await self.repository.update_fields(
|
||||
session_id,
|
||||
status=DemoSessionStatus.DESTROYED,
|
||||
destroyed_at=datetime.now(timezone.utc)
|
||||
)
|
||||
else:
|
||||
# Update status to FAILED with error details
|
||||
await self.repository.update_fields(
|
||||
session_id,
|
||||
status=DemoSessionStatus.FAILED,
|
||||
error_details=result["errors"]
|
||||
)
|
||||
|
||||
# Delete Redis data
|
||||
await self.redis.delete_session_data(session_id)
|
||||
@@ -227,9 +240,34 @@ class DemoSessionManager:
|
||||
logger.info(
|
||||
"Session destroyed",
|
||||
session_id=session_id,
|
||||
virtual_tenant_id=str(session.virtual_tenant_id)
|
||||
virtual_tenant_id=str(session.virtual_tenant_id),
|
||||
total_records_deleted=result.get("total_deleted", 0),
|
||||
duration_ms=result.get("duration_ms", 0)
|
||||
)
|
||||
|
||||
async def _check_database_disk_space(self):
|
||||
"""Check if database has sufficient disk space for demo operations"""
|
||||
try:
|
||||
# Execute a simple query to check database health and disk space
|
||||
# This is a basic check - in production you might want more comprehensive monitoring
|
||||
from sqlalchemy import text
|
||||
|
||||
# Check if we can execute a simple query (indicates basic database health)
|
||||
result = await self.db.execute(text("SELECT 1"))
|
||||
# Get the scalar result properly
|
||||
scalar_result = result.scalar_one_or_none()
|
||||
|
||||
# For more comprehensive checking, you could add:
|
||||
# 1. Check table sizes
|
||||
# 2. Check available disk space via system queries (if permissions allow)
|
||||
# 3. Check for long-running transactions that might block operations
|
||||
|
||||
logger.debug("Database health check passed", result=scalar_result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Database health check failed", error=str(e), exc_info=True)
|
||||
raise RuntimeError(f"Database health check failed: {str(e)}")
|
||||
|
||||
async def _store_session_metadata(self, session: DemoSession):
|
||||
"""Store session metadata in Redis"""
|
||||
await self.redis.set_session_data(
|
||||
@@ -274,6 +312,33 @@ class DemoSessionManager:
|
||||
virtual_tenant_id=str(session.virtual_tenant_id)
|
||||
)
|
||||
|
||||
# Check database disk space before starting cloning
|
||||
try:
|
||||
await self._check_database_disk_space()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Database disk space check failed",
|
||||
session_id=session.session_id,
|
||||
error=str(e)
|
||||
)
|
||||
# Mark session as failed due to infrastructure issue
|
||||
session.status = DemoSessionStatus.FAILED
|
||||
session.cloning_completed_at = datetime.now(timezone.utc)
|
||||
session.total_records_cloned = 0
|
||||
session.cloning_progress = {
|
||||
"error": "Database disk space issue detected",
|
||||
"details": str(e)
|
||||
}
|
||||
await self.repository.update(session)
|
||||
await self._cache_session_status(session)
|
||||
return {
|
||||
"overall_status": "failed",
|
||||
"services": {},
|
||||
"total_records": 0,
|
||||
"failed_services": ["database"],
|
||||
"error": "Database disk space issue"
|
||||
}
|
||||
|
||||
# Mark cloning as started and update both database and Redis cache
|
||||
session.cloning_started_at = datetime.now(timezone.utc)
|
||||
await self.repository.update(session)
|
||||
@@ -295,130 +360,7 @@ class DemoSessionManager:
|
||||
|
||||
return result
|
||||
|
||||
async def _validate_base_tenant_exists(self, base_tenant_id: uuid.UUID, demo_account_type: str) -> bool:
|
||||
"""
|
||||
Validate that the base tenant exists in the tenant service before starting cloning.
|
||||
This prevents cloning from non-existent base tenants.
|
||||
|
||||
Args:
|
||||
base_tenant_id: The UUID of the base tenant to validate
|
||||
demo_account_type: The demo account type for logging
|
||||
|
||||
Returns:
|
||||
True if tenant exists, raises exception otherwise
|
||||
"""
|
||||
logger.info(
|
||||
"Validating base tenant exists before cloning",
|
||||
base_tenant_id=str(base_tenant_id),
|
||||
demo_account_type=demo_account_type
|
||||
)
|
||||
|
||||
# Basic validation: check if UUID is valid (not empty/nil)
|
||||
if str(base_tenant_id) == "00000000-0000-0000-0000-000000000000":
|
||||
raise ValueError(f"Invalid base tenant ID: {base_tenant_id} for demo type: {demo_account_type}")
|
||||
|
||||
# BUG-008 FIX: Actually validate with tenant service
|
||||
try:
|
||||
from shared.clients.tenant_client import TenantServiceClient
|
||||
|
||||
tenant_client = TenantServiceClient(settings)
|
||||
tenant = await tenant_client.get_tenant(str(base_tenant_id))
|
||||
|
||||
if not tenant:
|
||||
error_msg = (
|
||||
f"Base tenant {base_tenant_id} does not exist for demo type {demo_account_type}. "
|
||||
f"Please verify the base_tenant_id in demo configuration."
|
||||
)
|
||||
logger.error(
|
||||
"Base tenant validation failed",
|
||||
base_tenant_id=str(base_tenant_id),
|
||||
demo_account_type=demo_account_type
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(
|
||||
"Base tenant validation passed",
|
||||
base_tenant_id=str(base_tenant_id),
|
||||
tenant_name=tenant.get("name", "unknown"),
|
||||
demo_account_type=demo_account_type
|
||||
)
|
||||
return True
|
||||
|
||||
except ValueError:
|
||||
# Re-raise ValueError from validation failure
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error validating base tenant: {e}",
|
||||
base_tenant_id=str(base_tenant_id),
|
||||
demo_account_type=demo_account_type,
|
||||
exc_info=True
|
||||
)
|
||||
raise ValueError(f"Cannot validate base tenant {base_tenant_id}: {str(e)}")
|
||||
|
||||
async def _validate_child_template_tenants(self, child_configs: list) -> bool:
|
||||
"""
|
||||
Validate that all child template tenants exist before cloning.
|
||||
This prevents silent failures when child base tenants are missing.
|
||||
|
||||
Args:
|
||||
child_configs: List of child configurations with base_tenant_id
|
||||
|
||||
Returns:
|
||||
True if all child templates exist, raises exception otherwise
|
||||
"""
|
||||
if not child_configs:
|
||||
logger.warning("No child configurations provided for validation")
|
||||
return True
|
||||
|
||||
logger.info("Validating child template tenants", child_count=len(child_configs))
|
||||
|
||||
try:
|
||||
from shared.clients.tenant_client import TenantServiceClient
|
||||
|
||||
tenant_client = TenantServiceClient(settings)
|
||||
|
||||
for child_config in child_configs:
|
||||
child_base_id = child_config.get("base_tenant_id")
|
||||
child_name = child_config.get("name", "unknown")
|
||||
|
||||
if not child_base_id:
|
||||
raise ValueError(f"Child config missing base_tenant_id: {child_name}")
|
||||
|
||||
# Validate child template exists
|
||||
child_tenant = await tenant_client.get_tenant(child_base_id)
|
||||
|
||||
if not child_tenant:
|
||||
error_msg = (
|
||||
f"Child template tenant {child_base_id} ('{child_name}') does not exist. "
|
||||
f"Please verify the base_tenant_id in demo configuration."
|
||||
)
|
||||
logger.error(
|
||||
"Child template validation failed",
|
||||
base_tenant_id=child_base_id,
|
||||
child_name=child_name
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.info(
|
||||
"Child template validation passed",
|
||||
base_tenant_id=child_base_id,
|
||||
child_name=child_name,
|
||||
tenant_name=child_tenant.get("name", "unknown")
|
||||
)
|
||||
|
||||
logger.info("All child template tenants validated successfully")
|
||||
return True
|
||||
|
||||
except ValueError:
|
||||
# Re-raise ValueError from validation failure
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error validating child template tenants: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
raise ValueError(f"Cannot validate child template tenants: {str(e)}")
|
||||
|
||||
async def _update_session_from_clone_result(
|
||||
self,
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
"""
|
||||
Internal Demo API for Distribution Service
|
||||
Handles internal demo setup for enterprise tier
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from typing import Dict, Any, List, Optional
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
|
||||
from app.services.distribution_service import DistributionService
|
||||
from app.api.dependencies import get_distribution_service
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
required_key = settings.INTERNAL_API_KEY
|
||||
if x_internal_api_key != required_key:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
# Legacy /internal/demo/setup and /internal/demo/cleanup endpoints removed
|
||||
# Distribution now uses the standard /internal/demo/clone pattern like all other services
|
||||
# Data is cloned from base template tenants via DataCloner
|
||||
|
||||
|
||||
@router.get("/internal/health")
|
||||
async def internal_health_check(
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Internal health check endpoint
|
||||
"""
|
||||
return {
|
||||
"service": "distribution-service",
|
||||
"endpoint": "internal-demo",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/internal/demo/clone")
|
||||
async def clone_demo_data(
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
demo_account_type: str,
|
||||
session_id: Optional[str] = None,
|
||||
session_created_at: Optional[str] = None,
|
||||
session_metadata: Optional[str] = None,
|
||||
distribution_service: DistributionService = Depends(get_distribution_service),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Clone distribution data from base tenant to virtual tenant
|
||||
|
||||
This follows the standard cloning pattern used by other services:
|
||||
1. Query base tenant data (routes, shipments, schedules)
|
||||
2. Clone to virtual tenant with ID substitution and date adjustment
|
||||
3. Return records cloned count
|
||||
|
||||
Args:
|
||||
base_tenant_id: Template tenant UUID to clone from
|
||||
virtual_tenant_id: Target virtual tenant UUID
|
||||
demo_account_type: Type of demo account
|
||||
session_id: Originating session ID for tracing
|
||||
session_created_at: ISO timestamp when demo session was created (for date adjustment)
|
||||
"""
|
||||
try:
|
||||
if not all([base_tenant_id, virtual_tenant_id, session_id]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Missing required parameters: base_tenant_id, virtual_tenant_id, session_id"
|
||||
)
|
||||
|
||||
logger.info("Cloning distribution data from base tenant",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
session_id=session_id)
|
||||
|
||||
# Clean up any existing demo data for this virtual tenant to prevent conflicts
|
||||
logger.info("Cleaning up existing demo data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
|
||||
deleted_routes = await distribution_service.route_repository.delete_demo_routes_for_tenant(virtual_tenant_id)
|
||||
deleted_shipments = await distribution_service.shipment_repository.delete_demo_shipments_for_tenant(virtual_tenant_id)
|
||||
|
||||
if deleted_routes > 0 or deleted_shipments > 0:
|
||||
logger.info("Cleaned up existing demo data",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
deleted_routes=deleted_routes,
|
||||
deleted_shipments=deleted_shipments)
|
||||
|
||||
# Generate a single timestamp suffix for this cloning operation to ensure uniqueness
|
||||
timestamp_suffix = str(int(time.time()))[-6:] # Last 6 digits of timestamp
|
||||
|
||||
# Parse session creation date for date adjustment
|
||||
from datetime import date, datetime, timezone
|
||||
from dateutil import parser as date_parser
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE, adjust_date_for_demo
|
||||
|
||||
if session_created_at:
|
||||
if isinstance(session_created_at, str):
|
||||
session_dt = date_parser.parse(session_created_at)
|
||||
else:
|
||||
session_dt = session_created_at
|
||||
else:
|
||||
session_dt = datetime.now(timezone.utc)
|
||||
|
||||
# Parse session_metadata to extract child tenant mappings for enterprise demos
|
||||
child_tenant_id_map = {}
|
||||
if session_metadata:
|
||||
try:
|
||||
metadata_dict = json.loads(session_metadata)
|
||||
child_configs = metadata_dict.get("child_configs", [])
|
||||
child_tenant_ids = metadata_dict.get("child_tenant_ids", [])
|
||||
|
||||
# Build mapping: base_child_id -> virtual_child_id
|
||||
for idx, child_config in enumerate(child_configs):
|
||||
if idx < len(child_tenant_ids):
|
||||
base_child_id = child_config.get("base_tenant_id")
|
||||
virtual_child_id = child_tenant_ids[idx]
|
||||
if base_child_id and virtual_child_id:
|
||||
child_tenant_id_map[base_child_id] = virtual_child_id
|
||||
|
||||
logger.info(
|
||||
"Built child tenant ID mapping for enterprise demo",
|
||||
mapping_count=len(child_tenant_id_map),
|
||||
session_id=session_id,
|
||||
mappings=child_tenant_id_map
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse session_metadata", error=str(e), session_id=session_id)
|
||||
|
||||
# Clone delivery routes from base tenant
|
||||
base_routes = await distribution_service.route_repository.get_all_routes_for_tenant(base_tenant_id)
|
||||
|
||||
routes_cloned = 0
|
||||
route_id_map = {} # Map old route IDs to new route IDs
|
||||
|
||||
for base_route in base_routes:
|
||||
# Adjust route_date relative to session creation
|
||||
adjusted_route_date = adjust_date_for_demo(
|
||||
base_route.get('route_date'),
|
||||
session_dt,
|
||||
BASE_REFERENCE_DATE
|
||||
)
|
||||
|
||||
# Map child tenant IDs in route_sequence
|
||||
route_sequence = base_route.get('route_sequence', [])
|
||||
if child_tenant_id_map and route_sequence:
|
||||
mapped_sequence = []
|
||||
for stop in route_sequence:
|
||||
if isinstance(stop, dict) and 'child_tenant_id' in stop:
|
||||
base_child_id = str(stop['child_tenant_id'])
|
||||
if base_child_id in child_tenant_id_map:
|
||||
stop = {**stop, 'child_tenant_id': child_tenant_id_map[base_child_id]}
|
||||
logger.debug(
|
||||
"Mapped child_tenant_id in route_sequence",
|
||||
base_child_id=base_child_id,
|
||||
virtual_child_id=child_tenant_id_map[base_child_id],
|
||||
session_id=session_id
|
||||
)
|
||||
mapped_sequence.append(stop)
|
||||
route_sequence = mapped_sequence
|
||||
|
||||
# Generate unique route number for the virtual tenant to avoid duplicates
|
||||
base_route_number = base_route.get('route_number')
|
||||
if base_route_number and base_route_number.startswith('DEMO-'):
|
||||
# For demo routes, append the virtual tenant ID to ensure uniqueness
|
||||
# Use more characters from UUID and include a timestamp component to reduce collision risk
|
||||
# Handle both string and UUID inputs for virtual_tenant_id
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(virtual_tenant_id) if isinstance(virtual_tenant_id, str) else virtual_tenant_id
|
||||
except (ValueError, TypeError):
|
||||
# If it's already a UUID object, use it directly
|
||||
tenant_uuid = virtual_tenant_id
|
||||
# Use more characters to make it more unique
|
||||
tenant_suffix = str(tenant_uuid).replace('-', '')[:16]
|
||||
# Use the single timestamp suffix generated at the start of the operation
|
||||
route_number = f"{base_route_number}-{tenant_suffix}-{timestamp_suffix}"
|
||||
else:
|
||||
# For non-demo routes, use original route number
|
||||
route_number = base_route_number
|
||||
|
||||
new_route = await distribution_service.route_repository.create_route({
|
||||
'tenant_id': uuid.UUID(virtual_tenant_id),
|
||||
'route_number': route_number,
|
||||
'route_date': adjusted_route_date,
|
||||
'vehicle_id': base_route.get('vehicle_id'),
|
||||
'driver_id': base_route.get('driver_id'),
|
||||
'total_distance_km': base_route.get('total_distance_km'),
|
||||
'estimated_duration_minutes': base_route.get('estimated_duration_minutes'),
|
||||
'route_sequence': route_sequence,
|
||||
'status': base_route.get('status')
|
||||
})
|
||||
routes_cloned += 1
|
||||
|
||||
# Map old route ID to the new route ID returned by the repository
|
||||
route_id_map[base_route.get('id')] = new_route['id']
|
||||
|
||||
# Clone shipments from base tenant
|
||||
base_shipments = await distribution_service.shipment_repository.get_all_shipments_for_tenant(base_tenant_id)
|
||||
|
||||
shipments_cloned = 0
|
||||
for base_shipment in base_shipments:
|
||||
# Adjust shipment_date relative to session creation
|
||||
adjusted_shipment_date = adjust_date_for_demo(
|
||||
base_shipment.get('shipment_date'),
|
||||
session_dt,
|
||||
BASE_REFERENCE_DATE
|
||||
)
|
||||
|
||||
# Map delivery_route_id to new route ID
|
||||
old_route_id = base_shipment.get('delivery_route_id')
|
||||
new_route_id = route_id_map.get(old_route_id) if old_route_id else None
|
||||
|
||||
# Generate unique shipment number for the virtual tenant to avoid duplicates
|
||||
base_shipment_number = base_shipment.get('shipment_number')
|
||||
if base_shipment_number and base_shipment_number.startswith('DEMO'):
|
||||
# For demo shipments, append the virtual tenant ID to ensure uniqueness
|
||||
# Use more characters from UUID and include a timestamp component to reduce collision risk
|
||||
# Handle both string and UUID inputs for virtual_tenant_id
|
||||
try:
|
||||
tenant_uuid = uuid.UUID(virtual_tenant_id) if isinstance(virtual_tenant_id, str) else virtual_tenant_id
|
||||
except (ValueError, TypeError):
|
||||
# If it's already a UUID object, use it directly
|
||||
tenant_uuid = virtual_tenant_id
|
||||
# Use more characters to make it more unique
|
||||
tenant_suffix = str(tenant_uuid).replace('-', '')[:16]
|
||||
# Use the single timestamp suffix generated at the start of the operation
|
||||
shipment_number = f"{base_shipment_number}-{tenant_suffix}-{timestamp_suffix}"
|
||||
else:
|
||||
# For non-demo shipments, use original shipment number
|
||||
shipment_number = base_shipment_number
|
||||
|
||||
# Map child_tenant_id to virtual child ID (THE KEY FIX)
|
||||
base_child_id = base_shipment.get('child_tenant_id')
|
||||
virtual_child_id = None
|
||||
if base_child_id:
|
||||
base_child_id_str = str(base_child_id)
|
||||
if child_tenant_id_map and base_child_id_str in child_tenant_id_map:
|
||||
virtual_child_id = uuid.UUID(child_tenant_id_map[base_child_id_str])
|
||||
logger.debug(
|
||||
"Mapped child tenant ID for shipment",
|
||||
base_child_id=base_child_id_str,
|
||||
virtual_child_id=str(virtual_child_id),
|
||||
shipment_number=shipment_number,
|
||||
session_id=session_id
|
||||
)
|
||||
else:
|
||||
virtual_child_id = base_child_id # Fallback to original
|
||||
else:
|
||||
virtual_child_id = None
|
||||
|
||||
new_shipment = await distribution_service.shipment_repository.create_shipment({
|
||||
'id': uuid.uuid4(),
|
||||
'tenant_id': uuid.UUID(virtual_tenant_id),
|
||||
'parent_tenant_id': uuid.UUID(virtual_tenant_id),
|
||||
'child_tenant_id': virtual_child_id, # Mapped child tenant ID
|
||||
'delivery_route_id': new_route_id,
|
||||
'shipment_number': shipment_number,
|
||||
'shipment_date': adjusted_shipment_date,
|
||||
'status': base_shipment.get('status'),
|
||||
'total_weight_kg': base_shipment.get('total_weight_kg'),
|
||||
'total_volume_m3': base_shipment.get('total_volume_m3'),
|
||||
'delivery_notes': base_shipment.get('delivery_notes')
|
||||
})
|
||||
shipments_cloned += 1
|
||||
|
||||
# Clone delivery schedules from base tenant
|
||||
base_schedules = await distribution_service.schedule_repository.get_schedules_by_tenant(base_tenant_id)
|
||||
|
||||
schedules_cloned = 0
|
||||
for base_schedule in base_schedules:
|
||||
# Map child_tenant_id to virtual child ID
|
||||
base_child_id = base_schedule.get('child_tenant_id')
|
||||
virtual_child_id = None
|
||||
if base_child_id:
|
||||
base_child_id_str = str(base_child_id)
|
||||
if child_tenant_id_map and base_child_id_str in child_tenant_id_map:
|
||||
virtual_child_id = uuid.UUID(child_tenant_id_map[base_child_id_str])
|
||||
logger.debug(
|
||||
"Mapped child tenant ID for delivery schedule",
|
||||
base_child_id=base_child_id_str,
|
||||
virtual_child_id=str(virtual_child_id),
|
||||
session_id=session_id
|
||||
)
|
||||
else:
|
||||
virtual_child_id = base_child_id # Fallback to original
|
||||
else:
|
||||
virtual_child_id = None
|
||||
|
||||
new_schedule = await distribution_service.schedule_repository.create_schedule({
|
||||
'id': uuid.uuid4(),
|
||||
'parent_tenant_id': uuid.UUID(virtual_tenant_id),
|
||||
'child_tenant_id': virtual_child_id, # Mapped child tenant ID
|
||||
'schedule_name': base_schedule.get('schedule_name'),
|
||||
'delivery_days': base_schedule.get('delivery_days'),
|
||||
'delivery_time': base_schedule.get('delivery_time'),
|
||||
'auto_generate_orders': base_schedule.get('auto_generate_orders'),
|
||||
'lead_time_days': base_schedule.get('lead_time_days'),
|
||||
'is_active': base_schedule.get('is_active')
|
||||
})
|
||||
schedules_cloned += 1
|
||||
|
||||
total_records = routes_cloned + shipments_cloned + schedules_cloned
|
||||
|
||||
logger.info(
|
||||
"Distribution cloning completed successfully",
|
||||
session_id=session_id,
|
||||
routes_cloned=routes_cloned,
|
||||
shipments_cloned=shipments_cloned,
|
||||
schedules_cloned=schedules_cloned,
|
||||
total_records=total_records,
|
||||
child_mappings_applied=len(child_tenant_id_map),
|
||||
is_enterprise=len(child_tenant_id_map) > 0
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "distribution",
|
||||
"status": "completed",
|
||||
"records_cloned": total_records,
|
||||
"routes_cloned": routes_cloned,
|
||||
"shipments_cloned": shipments_cloned,
|
||||
"schedules_cloned": schedules_cloned
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cloning distribution data: {e}", exc_info=True)
|
||||
# Don't fail the entire cloning process if distribution fails, but add more context
|
||||
error_msg = f"Distribution cloning failed: {str(e)}"
|
||||
logger.warning(f"Distribution cloning partially failed but continuing: {error_msg}")
|
||||
return {
|
||||
"service": "distribution",
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"records_cloned": 0,
|
||||
"routes_cloned": 0,
|
||||
"shipments_cloned": 0,
|
||||
"schedules_cloned": 0
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/internal/demo/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_data(
|
||||
virtual_tenant_id: str,
|
||||
distribution_service: DistributionService = Depends(get_distribution_service),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""Delete all distribution data for a virtual demo tenant"""
|
||||
try:
|
||||
logger.info("Deleting distribution data", virtual_tenant_id=virtual_tenant_id)
|
||||
|
||||
# Reuse existing cleanup logic
|
||||
deleted_routes = await distribution_service.route_repository.delete_demo_routes_for_tenant(
|
||||
tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
deleted_shipments = await distribution_service.shipment_repository.delete_demo_shipments_for_tenant(
|
||||
tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "distribution",
|
||||
"status": "deleted",
|
||||
"virtual_tenant_id": virtual_tenant_id,
|
||||
"records_deleted": {
|
||||
"routes": deleted_routes,
|
||||
"shipments": deleted_shipments
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting distribution data: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -8,7 +8,7 @@ from app.core.config import settings
|
||||
from app.core.database import database_manager
|
||||
from app.api.routes import router as distribution_router
|
||||
from app.api.shipments import router as shipments_router
|
||||
from app.api.internal_demo import router as internal_demo_router
|
||||
# from app.api.internal_demo import router as internal_demo_router # REMOVED: Replaced by script-based seed data loading
|
||||
from shared.service_base import StandardFastAPIService
|
||||
|
||||
|
||||
@@ -122,4 +122,4 @@ service.setup_standard_endpoints()
|
||||
# Note: Routes now use RouteBuilder which includes full paths, so no prefix needed
|
||||
service.add_router(distribution_router, tags=["distribution"])
|
||||
service.add_router(shipments_router, tags=["shipments"])
|
||||
service.add_router(internal_demo_router, tags=["internal-demo"])
|
||||
# service.add_router(internal_demo_router, tags=["internal-demo"]) # REMOVED: Replaced by script-based seed data loading
|
||||
@@ -1,300 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Distribution History Seeding Script for Distribution Service
|
||||
Creates 30 days of historical delivery routes and shipments for enterprise demo
|
||||
|
||||
This is the CRITICAL missing piece that connects parent (Obrador) to children (retail outlets).
|
||||
It populates the template with realistic VRP-optimized delivery routes.
|
||||
|
||||
Usage:
|
||||
python /app/scripts/demo/seed_demo_distribution_history.py
|
||||
|
||||
Environment Variables Required:
|
||||
DISTRIBUTION_DATABASE_URL - PostgreSQL connection string
|
||||
DEMO_MODE - Set to 'production' for production seeding
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
# Add shared to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
from app.models import DeliveryRoute, Shipment, DeliveryRouteStatus, ShipmentStatus
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer()
|
||||
]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Fixed Demo Tenant IDs
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Parent (Obrador)
|
||||
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") # Madrid Centro
|
||||
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") # Barcelona Gràcia
|
||||
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") # Valencia Ruzafa
|
||||
|
||||
CHILD_TENANTS = [
|
||||
(DEMO_TENANT_CHILD_1, "Madrid Centro", 150.0),
|
||||
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia", 120.0),
|
||||
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa", 100.0)
|
||||
]
|
||||
|
||||
# Delivery schedule: Mon/Wed/Fri (as per distribution service)
|
||||
DELIVERY_WEEKDAYS = [0, 2, 4] # Monday, Wednesday, Friday
|
||||
|
||||
|
||||
async def seed_distribution_history(db: AsyncSession):
|
||||
"""
|
||||
Seed 30 days of distribution data (routes + shipments) centered around BASE_REFERENCE_DATE
|
||||
|
||||
Creates delivery routes for Mon/Wed/Fri pattern spanning from 15 days before to 15 days after BASE_REFERENCE_DATE.
|
||||
This ensures data exists for today when BASE_REFERENCE_DATE is set to the current date.
|
||||
"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("🚚 Starting Demo Distribution History Seeding")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Parent Tenant: {DEMO_TENANT_ENTERPRISE_CHAIN} (Obrador Madrid)")
|
||||
logger.info(f"Child Tenants: {len(CHILD_TENANTS)}")
|
||||
logger.info(f"Delivery Pattern: Mon/Wed/Fri (3x per week)")
|
||||
logger.info(f"Date Range: {(BASE_REFERENCE_DATE - timedelta(days=15)).strftime('%Y-%m-%d')} to {(BASE_REFERENCE_DATE + timedelta(days=15)).strftime('%Y-%m-%d')}")
|
||||
logger.info(f"Reference Date (today): {BASE_REFERENCE_DATE.strftime('%Y-%m-%d')}")
|
||||
logger.info("")
|
||||
|
||||
routes_created = 0
|
||||
shipments_created = 0
|
||||
|
||||
# Generate 30 days of routes centered around BASE_REFERENCE_DATE (-15 to +15 days)
|
||||
# This ensures we have past data, current data, and future data
|
||||
# Range is inclusive of start, exclusive of end, so -15 to 16 gives -15..15
|
||||
for days_offset in range(-15, 16): # -15 to +15 = 31 days total
|
||||
delivery_date = BASE_REFERENCE_DATE + timedelta(days=days_offset)
|
||||
|
||||
# Only create routes for Mon/Wed/Fri
|
||||
if delivery_date.weekday() not in DELIVERY_WEEKDAYS:
|
||||
continue
|
||||
|
||||
# Check if route already exists
|
||||
result = await db.execute(
|
||||
select(DeliveryRoute).where(
|
||||
DeliveryRoute.tenant_id == DEMO_TENANT_ENTERPRISE_CHAIN,
|
||||
DeliveryRoute.route_date == delivery_date
|
||||
).limit(1)
|
||||
)
|
||||
existing_route = result.scalar_one_or_none()
|
||||
|
||||
if existing_route:
|
||||
logger.debug(f"Route already exists for {delivery_date.strftime('%Y-%m-%d')}, skipping")
|
||||
continue
|
||||
|
||||
# Create delivery route
|
||||
route_number = f"DEMO-{delivery_date.strftime('%Y%m%d')}-001"
|
||||
|
||||
# Realistic VRP metrics for 3-stop route
|
||||
# Distance: Madrid Centro (closest) + Barcelona Gràcia (medium) + Valencia Ruzafa (farthest)
|
||||
total_distance_km = random.uniform(75.0, 95.0) # Realistic for 3 retail outlets in region
|
||||
estimated_duration_minutes = random.randint(180, 240) # 3-4 hours for 3 stops
|
||||
|
||||
# Route sequence (order of deliveries) with full GPS coordinates for map display
|
||||
# Determine status based on date
|
||||
is_past = delivery_date < BASE_REFERENCE_DATE
|
||||
point_status = "delivered" if is_past else "pending"
|
||||
|
||||
route_sequence = [
|
||||
{
|
||||
"tenant_id": str(DEMO_TENANT_CHILD_1),
|
||||
"name": "Madrid Centro",
|
||||
"address": "Calle Gran Vía 28, 28013 Madrid, Spain",
|
||||
"latitude": 40.4168,
|
||||
"longitude": -3.7038,
|
||||
"status": point_status,
|
||||
"id": str(uuid.uuid4()),
|
||||
"sequence": 1
|
||||
},
|
||||
{
|
||||
"tenant_id": str(DEMO_TENANT_CHILD_2),
|
||||
"name": "Barcelona Gràcia",
|
||||
"address": "Carrer Gran de Gràcia 15, 08012 Barcelona, Spain",
|
||||
"latitude": 41.4036,
|
||||
"longitude": 2.1561,
|
||||
"status": point_status,
|
||||
"id": str(uuid.uuid4()),
|
||||
"sequence": 2
|
||||
},
|
||||
{
|
||||
"tenant_id": str(DEMO_TENANT_CHILD_3),
|
||||
"name": "Valencia Ruzafa",
|
||||
"address": "Carrer de Sueca 51, 46006 Valencia, Spain",
|
||||
"latitude": 39.4647,
|
||||
"longitude": -0.3679,
|
||||
"status": point_status,
|
||||
"id": str(uuid.uuid4()),
|
||||
"sequence": 3
|
||||
}
|
||||
]
|
||||
|
||||
# Route status (already determined is_past above)
|
||||
route_status = DeliveryRouteStatus.completed if is_past else DeliveryRouteStatus.planned
|
||||
|
||||
route = DeliveryRoute(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=DEMO_TENANT_ENTERPRISE_CHAIN,
|
||||
route_number=route_number,
|
||||
route_date=delivery_date,
|
||||
total_distance_km=Decimal(str(round(total_distance_km, 2))),
|
||||
estimated_duration_minutes=estimated_duration_minutes,
|
||||
route_sequence=route_sequence,
|
||||
status=route_status,
|
||||
driver_id=uuid.uuid4(), # Use a random UUID for the driver_id
|
||||
vehicle_id=f"VEH-{random.choice(['001', '002', '003'])}",
|
||||
created_at=delivery_date - timedelta(days=1), # Routes created day before
|
||||
updated_at=delivery_date,
|
||||
created_by=uuid.uuid4(), # Add required audit field
|
||||
updated_by=uuid.uuid4() # Add required audit field
|
||||
)
|
||||
|
||||
db.add(route)
|
||||
routes_created += 1
|
||||
|
||||
# Create shipments for each child tenant on this route
|
||||
for child_tenant_id, child_name, avg_weight_kg in CHILD_TENANTS:
|
||||
# Vary weight slightly
|
||||
shipment_weight = avg_weight_kg * random.uniform(0.9, 1.1)
|
||||
|
||||
shipment_number = f"DEMOSHP-{delivery_date.strftime('%Y%m%d')}-{child_name.split()[0].upper()[:3]}"
|
||||
|
||||
# Determine shipment status based on date
|
||||
shipment_status = ShipmentStatus.delivered if is_past else ShipmentStatus.pending
|
||||
|
||||
shipment = Shipment(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=DEMO_TENANT_ENTERPRISE_CHAIN,
|
||||
parent_tenant_id=DEMO_TENANT_ENTERPRISE_CHAIN,
|
||||
child_tenant_id=child_tenant_id,
|
||||
shipment_number=shipment_number,
|
||||
shipment_date=delivery_date,
|
||||
status=shipment_status,
|
||||
total_weight_kg=Decimal(str(round(shipment_weight, 2))),
|
||||
delivery_route_id=route.id,
|
||||
delivery_notes=f"Entrega regular a {child_name}",
|
||||
created_at=delivery_date - timedelta(days=1),
|
||||
updated_at=delivery_date,
|
||||
created_by=uuid.uuid4(), # Add required audit field
|
||||
updated_by=uuid.uuid4() # Add required audit field
|
||||
)
|
||||
|
||||
db.add(shipment)
|
||||
shipments_created += 1
|
||||
|
||||
logger.debug(
|
||||
f" ✅ {delivery_date.strftime('%a %Y-%m-%d')}: "
|
||||
f"Route {route_number} with {len(CHILD_TENANTS)} shipments"
|
||||
)
|
||||
|
||||
# Commit all changes
|
||||
await db.commit()
|
||||
|
||||
logger.info("")
|
||||
logger.info("=" * 80)
|
||||
logger.info("✅ Demo Distribution History Seeding Completed")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f" 📊 Routes created: {routes_created}")
|
||||
logger.info(f" 📦 Shipments created: {shipments_created}")
|
||||
logger.info("")
|
||||
logger.info("Distribution characteristics:")
|
||||
logger.info(" ✓ 30 days of historical data")
|
||||
logger.info(" ✓ Mon/Wed/Fri delivery schedule (3x per week)")
|
||||
logger.info(" ✓ VRP-optimized route sequencing")
|
||||
logger.info(" ✓ ~13 routes (30 days ÷ 7 days/week × 3 delivery days)")
|
||||
logger.info(" ✓ ~39 shipments (13 routes × 3 children)")
|
||||
logger.info(" ✓ Realistic distances and durations")
|
||||
logger.info("")
|
||||
|
||||
return {
|
||||
"service": "distribution",
|
||||
"routes_created": routes_created,
|
||||
"shipments_created": shipments_created
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main execution function"""
|
||||
|
||||
logger.info("Demo Distribution History Seeding Script Starting")
|
||||
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("DISTRIBUTION_DATABASE_URL") or os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("❌ DISTRIBUTION_DATABASE_URL or DATABASE_URL environment variable must be set")
|
||||
return 1
|
||||
|
||||
# Convert to async URL if needed
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
logger.info("Connecting to distribution database")
|
||||
|
||||
# Create engine and session
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10
|
||||
)
|
||||
|
||||
async_session = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await seed_distribution_history(session)
|
||||
|
||||
logger.info("🎉 Success! Distribution history is ready for cloning.")
|
||||
logger.info("")
|
||||
logger.info("Next steps:")
|
||||
logger.info(" 1. Create Kubernetes job YAMLs for all child scripts")
|
||||
logger.info(" 2. Update kustomization.yaml with proper execution order")
|
||||
logger.info(" 3. Test enterprise demo end-to-end")
|
||||
logger.info("")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("❌ Demo Distribution History Seeding Failed")
|
||||
logger.error("=" * 80)
|
||||
logger.error("Error: %s", str(e))
|
||||
logger.error("", exc_info=True)
|
||||
return 1
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -13,6 +13,7 @@ from typing import Optional
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
|
||||
@@ -21,7 +22,7 @@ from app.core.database import get_db
|
||||
from app.models.forecasts import Forecast, PredictionBatch
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal/demo", tags=["internal"])
|
||||
router = APIRouter()
|
||||
|
||||
# Base demo tenant IDs
|
||||
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
||||
@@ -36,7 +37,7 @@ def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/clone")
|
||||
@router.post("/internal/demo/clone")
|
||||
async def clone_demo_data(
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
@@ -49,144 +50,246 @@ async def clone_demo_data(
|
||||
"""
|
||||
Clone forecasting service data for a virtual demo tenant
|
||||
|
||||
Clones:
|
||||
- Forecasts (historical predictions)
|
||||
- Prediction batches (batch prediction records)
|
||||
This endpoint creates fresh demo data by:
|
||||
1. Loading seed data from JSON files
|
||||
2. Applying XOR-based ID transformation
|
||||
3. Adjusting dates relative to session creation time
|
||||
4. Creating records in the virtual tenant
|
||||
|
||||
Args:
|
||||
base_tenant_id: Template tenant UUID to clone from
|
||||
base_tenant_id: Template tenant UUID (for reference)
|
||||
virtual_tenant_id: Target virtual tenant UUID
|
||||
demo_account_type: Type of demo account
|
||||
session_id: Originating session ID for tracing
|
||||
session_created_at: ISO timestamp when demo session was created (for date adjustment)
|
||||
session_created_at: Session creation timestamp for date adjustment
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Cloning status and record counts
|
||||
Dictionary with cloning results
|
||||
|
||||
Raises:
|
||||
HTTPException: On validation or cloning errors
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Parse session_created_at or fallback to now
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning(
|
||||
"Invalid session_created_at format, using current time",
|
||||
session_created_at=session_created_at,
|
||||
error=str(e)
|
||||
)
|
||||
session_time = datetime.now(timezone.utc)
|
||||
else:
|
||||
logger.warning("session_created_at not provided, using current time")
|
||||
session_time = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
"Starting forecasting data cloning",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
demo_account_type=demo_account_type,
|
||||
session_id=session_id,
|
||||
session_time=session_time.isoformat()
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate UUIDs
|
||||
base_uuid = uuid.UUID(base_tenant_id)
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
|
||||
# Parse session creation time for date adjustment
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
session_time = start_time
|
||||
else:
|
||||
session_time = start_time
|
||||
|
||||
logger.info(
|
||||
"Starting forecasting data cloning with date adjustment",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=str(virtual_uuid),
|
||||
demo_account_type=demo_account_type,
|
||||
session_id=session_id,
|
||||
session_time=session_time.isoformat()
|
||||
)
|
||||
|
||||
# Load seed data using shared utility
|
||||
try:
|
||||
from shared.utils.seed_data_paths import get_seed_data_path
|
||||
|
||||
if demo_account_type == "enterprise":
|
||||
profile = "enterprise"
|
||||
else:
|
||||
profile = "professional"
|
||||
|
||||
json_file = get_seed_data_path(profile, "10-forecasting.json")
|
||||
|
||||
except ImportError:
|
||||
# Fallback to original path
|
||||
seed_data_dir = Path(__file__).parent.parent.parent.parent / "shared" / "demo" / "fixtures"
|
||||
if demo_account_type == "enterprise":
|
||||
json_file = seed_data_dir / "enterprise" / "parent" / "10-forecasting.json"
|
||||
else:
|
||||
json_file = seed_data_dir / "professional" / "10-forecasting.json"
|
||||
|
||||
if not json_file.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Seed data file not found: {json_file}"
|
||||
)
|
||||
|
||||
# Load JSON data
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
seed_data = json.load(f)
|
||||
|
||||
# Check if data already exists for this virtual tenant (idempotency)
|
||||
existing_check = await db.execute(
|
||||
select(Forecast).where(Forecast.tenant_id == virtual_uuid).limit(1)
|
||||
)
|
||||
existing_forecast = existing_check.scalar_one_or_none()
|
||||
|
||||
if existing_forecast:
|
||||
logger.warning(
|
||||
"Demo data already exists, skipping clone",
|
||||
virtual_tenant_id=str(virtual_uuid)
|
||||
)
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "Data already exists",
|
||||
"records_cloned": 0
|
||||
}
|
||||
|
||||
# Track cloning statistics
|
||||
stats = {
|
||||
"forecasts": 0,
|
||||
"prediction_batches": 0
|
||||
}
|
||||
|
||||
# Clone Forecasts
|
||||
result = await db.execute(
|
||||
select(Forecast).where(Forecast.tenant_id == base_uuid)
|
||||
)
|
||||
base_forecasts = result.scalars().all()
|
||||
# Transform and insert forecasts
|
||||
for forecast_data in seed_data.get('forecasts', []):
|
||||
# Transform ID using XOR
|
||||
from shared.utils.demo_id_transformer import transform_id
|
||||
try:
|
||||
forecast_uuid = uuid.UUID(forecast_data['id'])
|
||||
tenant_uuid = uuid.UUID(virtual_tenant_id)
|
||||
transformed_id = transform_id(forecast_data['id'], tenant_uuid)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to parse UUIDs for ID transformation",
|
||||
forecast_id=forecast_data['id'],
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid UUID format in forecast data: {str(e)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Found forecasts to clone",
|
||||
count=len(base_forecasts),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
# Transform dates
|
||||
for date_field in ['forecast_date', 'created_at']:
|
||||
if date_field in forecast_data:
|
||||
try:
|
||||
date_value = forecast_data[date_field]
|
||||
if isinstance(date_value, str):
|
||||
original_date = datetime.fromisoformat(date_value)
|
||||
elif hasattr(date_value, 'isoformat'):
|
||||
original_date = date_value
|
||||
else:
|
||||
logger.warning("Skipping invalid date format",
|
||||
date_field=date_field,
|
||||
date_value=date_value)
|
||||
continue
|
||||
|
||||
for forecast in base_forecasts:
|
||||
adjusted_forecast_date = adjust_date_for_demo(
|
||||
forecast.forecast_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if forecast.forecast_date else None
|
||||
adjusted_forecast_date = adjust_date_for_demo(
|
||||
original_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
)
|
||||
forecast_data[date_field] = adjusted_forecast_date
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning("Failed to parse date, skipping",
|
||||
date_field=date_field,
|
||||
date_value=forecast_data[date_field],
|
||||
error=str(e))
|
||||
forecast_data.pop(date_field, None)
|
||||
|
||||
# Create forecast
|
||||
# Map product_id to inventory_product_id if needed
|
||||
inventory_product_id = forecast_data.get('inventory_product_id') or forecast_data.get('product_id')
|
||||
|
||||
# Map predicted_quantity to predicted_demand if needed
|
||||
predicted_demand = forecast_data.get('predicted_demand') or forecast_data.get('predicted_quantity')
|
||||
|
||||
new_forecast = Forecast(
|
||||
id=uuid.uuid4(),
|
||||
id=transformed_id,
|
||||
tenant_id=virtual_uuid,
|
||||
inventory_product_id=forecast.inventory_product_id, # Keep product reference
|
||||
product_name=forecast.product_name,
|
||||
location=forecast.location,
|
||||
forecast_date=adjusted_forecast_date,
|
||||
created_at=session_time,
|
||||
predicted_demand=forecast.predicted_demand,
|
||||
confidence_lower=forecast.confidence_lower,
|
||||
confidence_upper=forecast.confidence_upper,
|
||||
confidence_level=forecast.confidence_level,
|
||||
model_id=forecast.model_id,
|
||||
model_version=forecast.model_version,
|
||||
algorithm=forecast.algorithm,
|
||||
business_type=forecast.business_type,
|
||||
day_of_week=forecast.day_of_week,
|
||||
is_holiday=forecast.is_holiday,
|
||||
is_weekend=forecast.is_weekend,
|
||||
weather_temperature=forecast.weather_temperature,
|
||||
weather_precipitation=forecast.weather_precipitation,
|
||||
weather_description=forecast.weather_description,
|
||||
traffic_volume=forecast.traffic_volume,
|
||||
processing_time_ms=forecast.processing_time_ms,
|
||||
features_used=forecast.features_used
|
||||
inventory_product_id=inventory_product_id,
|
||||
product_name=forecast_data.get('product_name'),
|
||||
location=forecast_data.get('location'),
|
||||
forecast_date=forecast_data.get('forecast_date'),
|
||||
created_at=forecast_data.get('created_at', session_time),
|
||||
predicted_demand=predicted_demand,
|
||||
confidence_lower=forecast_data.get('confidence_lower'),
|
||||
confidence_upper=forecast_data.get('confidence_upper'),
|
||||
confidence_level=forecast_data.get('confidence_level', 0.8),
|
||||
model_id=forecast_data.get('model_id'),
|
||||
model_version=forecast_data.get('model_version'),
|
||||
algorithm=forecast_data.get('algorithm', 'prophet'),
|
||||
business_type=forecast_data.get('business_type', 'individual'),
|
||||
day_of_week=forecast_data.get('day_of_week'),
|
||||
is_holiday=forecast_data.get('is_holiday', False),
|
||||
is_weekend=forecast_data.get('is_weekend', False),
|
||||
weather_temperature=forecast_data.get('weather_temperature'),
|
||||
weather_precipitation=forecast_data.get('weather_precipitation'),
|
||||
weather_description=forecast_data.get('weather_description'),
|
||||
traffic_volume=forecast_data.get('traffic_volume'),
|
||||
processing_time_ms=forecast_data.get('processing_time_ms'),
|
||||
features_used=forecast_data.get('features_used')
|
||||
)
|
||||
db.add(new_forecast)
|
||||
stats["forecasts"] += 1
|
||||
|
||||
# Clone Prediction Batches
|
||||
result = await db.execute(
|
||||
select(PredictionBatch).where(PredictionBatch.tenant_id == base_uuid)
|
||||
)
|
||||
base_batches = result.scalars().all()
|
||||
# Transform and insert prediction batches
|
||||
for batch_data in seed_data.get('prediction_batches', []):
|
||||
# Transform ID using XOR
|
||||
from shared.utils.demo_id_transformer import transform_id
|
||||
try:
|
||||
batch_uuid = uuid.UUID(batch_data['id'])
|
||||
tenant_uuid = uuid.UUID(virtual_tenant_id)
|
||||
transformed_id = transform_id(batch_data['id'], tenant_uuid)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to parse UUIDs for ID transformation",
|
||||
batch_id=batch_data['id'],
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid UUID format in batch data: {str(e)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Found prediction batches to clone",
|
||||
count=len(base_batches),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
# Transform dates
|
||||
for date_field in ['requested_at', 'completed_at']:
|
||||
if date_field in batch_data:
|
||||
try:
|
||||
date_value = batch_data[date_field]
|
||||
if isinstance(date_value, str):
|
||||
original_date = datetime.fromisoformat(date_value)
|
||||
elif hasattr(date_value, 'isoformat'):
|
||||
original_date = date_value
|
||||
else:
|
||||
logger.warning("Skipping invalid date format",
|
||||
date_field=date_field,
|
||||
date_value=date_value)
|
||||
continue
|
||||
|
||||
for batch in base_batches:
|
||||
adjusted_requested_at = adjust_date_for_demo(
|
||||
batch.requested_at,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if batch.requested_at else None
|
||||
adjusted_completed_at = adjust_date_for_demo(
|
||||
batch.completed_at,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) if batch.completed_at else None
|
||||
adjusted_batch_date = adjust_date_for_demo(
|
||||
original_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
)
|
||||
batch_data[date_field] = adjusted_batch_date
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning("Failed to parse date, skipping",
|
||||
date_field=date_field,
|
||||
date_value=batch_data[date_field],
|
||||
error=str(e))
|
||||
batch_data.pop(date_field, None)
|
||||
|
||||
# Create prediction batch
|
||||
new_batch = PredictionBatch(
|
||||
id=uuid.uuid4(),
|
||||
id=transformed_id,
|
||||
tenant_id=virtual_uuid,
|
||||
batch_name=batch.batch_name,
|
||||
requested_at=adjusted_requested_at,
|
||||
completed_at=adjusted_completed_at,
|
||||
status=batch.status,
|
||||
total_products=batch.total_products,
|
||||
completed_products=batch.completed_products,
|
||||
failed_products=batch.failed_products,
|
||||
forecast_days=batch.forecast_days,
|
||||
business_type=batch.business_type,
|
||||
error_message=batch.error_message,
|
||||
processing_time_ms=batch.processing_time_ms,
|
||||
cancelled_by=batch.cancelled_by
|
||||
batch_name=batch_data.get('batch_name'),
|
||||
requested_at=batch_data.get('requested_at'),
|
||||
completed_at=batch_data.get('completed_at'),
|
||||
status=batch_data.get('status'),
|
||||
total_products=batch_data.get('total_products'),
|
||||
completed_products=batch_data.get('completed_products'),
|
||||
failed_products=batch_data.get('failed_products'),
|
||||
forecast_days=batch_data.get('forecast_days'),
|
||||
business_type=batch_data.get('business_type'),
|
||||
error_message=batch_data.get('error_message'),
|
||||
processing_time_ms=batch_data.get('processing_time_ms'),
|
||||
cancelled_by=batch_data.get('cancelled_by')
|
||||
)
|
||||
db.add(new_batch)
|
||||
stats["prediction_batches"] += 1
|
||||
@@ -198,11 +301,12 @@ async def clone_demo_data(
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Forecasting data cloning completed",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
total_records=total_records,
|
||||
stats=stats,
|
||||
duration_ms=duration_ms
|
||||
"Forecasting data cloned successfully",
|
||||
virtual_tenant_id=str(virtual_uuid),
|
||||
records_cloned=total_records,
|
||||
duration_ms=duration_ms,
|
||||
forecasts_cloned=stats["forecasts"],
|
||||
batches_cloned=stats["prediction_batches"]
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -210,11 +314,15 @@ async def clone_demo_data(
|
||||
"status": "completed",
|
||||
"records_cloned": total_records,
|
||||
"duration_ms": duration_ms,
|
||||
"details": stats
|
||||
"details": {
|
||||
"forecasts": stats["forecasts"],
|
||||
"prediction_batches": stats["prediction_batches"],
|
||||
"virtual_tenant_id": str(virtual_uuid)
|
||||
}
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e))
|
||||
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
@@ -248,3 +356,73 @@ async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
|
||||
"clone_endpoint": "available",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_tenant_data(
|
||||
virtual_tenant_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Delete all demo data for a virtual tenant.
|
||||
This endpoint is idempotent - safe to call multiple times.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
records_deleted = {
|
||||
"forecasts": 0,
|
||||
"prediction_batches": 0,
|
||||
"total": 0
|
||||
}
|
||||
|
||||
try:
|
||||
# Delete in reverse dependency order
|
||||
|
||||
# 1. Delete prediction batches
|
||||
result = await db.execute(
|
||||
delete(PredictionBatch)
|
||||
.where(PredictionBatch.tenant_id == virtual_tenant_id)
|
||||
)
|
||||
records_deleted["prediction_batches"] = result.rowcount
|
||||
|
||||
# 2. Delete forecasts
|
||||
result = await db.execute(
|
||||
delete(Forecast)
|
||||
.where(Forecast.tenant_id == virtual_tenant_id)
|
||||
)
|
||||
records_deleted["forecasts"] = result.rowcount
|
||||
|
||||
records_deleted["total"] = sum(records_deleted.values())
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"demo_data_deleted",
|
||||
service="forecasting",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
records_deleted=records_deleted
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "forecasting",
|
||||
"status": "deleted",
|
||||
"virtual_tenant_id": str(virtual_tenant_id),
|
||||
"records_deleted": records_deleted,
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"demo_data_deletion_failed",
|
||||
service="forecasting",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete demo data: {str(e)}"
|
||||
)
|
||||
@@ -14,7 +14,7 @@ from app.services.forecasting_alert_service import ForecastingAlertService
|
||||
from shared.service_base import StandardFastAPIService
|
||||
|
||||
# Import API routers
|
||||
from app.api import forecasts, forecasting_operations, analytics, scenario_operations, internal_demo, audit, ml_insights, validation, historical_validation, webhooks, performance_monitoring, retraining, enterprise_forecasting
|
||||
from app.api import forecasts, forecasting_operations, analytics, scenario_operations, audit, ml_insights, validation, historical_validation, webhooks, performance_monitoring, retraining, enterprise_forecasting, internal_demo
|
||||
|
||||
|
||||
class ForecastingService(StandardFastAPIService):
|
||||
@@ -188,7 +188,7 @@ service.add_router(forecasts.router)
|
||||
service.add_router(forecasting_operations.router)
|
||||
service.add_router(analytics.router)
|
||||
service.add_router(scenario_operations.router)
|
||||
service.add_router(internal_demo.router)
|
||||
service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||
service.add_router(ml_insights.router) # ML insights endpoint
|
||||
service.add_router(validation.router) # Validation endpoint
|
||||
service.add_router(historical_validation.router) # Historical validation endpoint
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Forecasting Seeding Script for Forecasting Service
|
||||
Creates demand forecasts and prediction batches for demo template tenants
|
||||
|
||||
This script runs as a Kubernetes init job inside the forecasting-service container.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from app.models.forecasts import Forecast, PredictionBatch
|
||||
|
||||
|
||||
# Add shared path for demo utilities
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
|
||||
# Configure logging
|
||||
logger = structlog.get_logger()
|
||||
|
||||
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
|
||||
|
||||
# Day of week mapping
|
||||
DAYS_OF_WEEK = {
|
||||
0: "lunes",
|
||||
1: "martes",
|
||||
2: "miercoles",
|
||||
3: "jueves",
|
||||
4: "viernes",
|
||||
5: "sabado",
|
||||
6: "domingo"
|
||||
}
|
||||
|
||||
|
||||
def load_forecasting_config():
|
||||
"""Load forecasting configuration from JSON file"""
|
||||
config_file = Path(__file__).parent / "previsiones_config_es.json"
|
||||
if not config_file.exists():
|
||||
raise FileNotFoundError(f"Forecasting config file not found: {config_file}")
|
||||
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def calculate_datetime_from_offset(offset_days: int) -> datetime:
|
||||
"""Calculate a datetime based on offset from BASE_REFERENCE_DATE"""
|
||||
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
|
||||
|
||||
|
||||
def weighted_choice(choices: list) -> dict:
|
||||
"""Make a weighted random choice from list of dicts with 'peso' key"""
|
||||
total_weight = sum(c.get("peso", 1.0) for c in choices)
|
||||
r = random.uniform(0, total_weight)
|
||||
|
||||
cumulative = 0
|
||||
for choice in choices:
|
||||
cumulative += choice.get("peso", 1.0)
|
||||
if r <= cumulative:
|
||||
return choice
|
||||
|
||||
return choices[-1]
|
||||
|
||||
|
||||
def calculate_demand(
|
||||
product: dict,
|
||||
day_of_week: int,
|
||||
is_weekend: bool,
|
||||
weather_temp: float,
|
||||
weather_precip: float,
|
||||
traffic_volume: int,
|
||||
config: dict
|
||||
) -> float:
|
||||
"""Calculate predicted demand based on various factors"""
|
||||
|
||||
# Base demand
|
||||
base_demand = product["demanda_base_diaria"]
|
||||
|
||||
# Weekly trend factor
|
||||
day_name = DAYS_OF_WEEK[day_of_week]
|
||||
weekly_factor = product["tendencia_semanal"][day_name]
|
||||
|
||||
# Apply seasonality (simple growth factor for "creciente")
|
||||
seasonality_factor = 1.0
|
||||
if product["estacionalidad"] == "creciente":
|
||||
seasonality_factor = 1.05
|
||||
|
||||
# Weather impact (simple model)
|
||||
weather_factor = 1.0
|
||||
temp_impact = config["configuracion_previsiones"]["factores_externos"]["temperatura"]["impacto_demanda"]
|
||||
precip_impact = config["configuracion_previsiones"]["factores_externos"]["precipitacion"]["impacto_demanda"]
|
||||
|
||||
if weather_temp > 22.0:
|
||||
weather_factor += temp_impact * (weather_temp - 22.0) / 10.0
|
||||
if weather_precip > 0:
|
||||
weather_factor += precip_impact
|
||||
|
||||
# Traffic correlation
|
||||
traffic_correlation = config["configuracion_previsiones"]["factores_externos"]["volumen_trafico"]["correlacion_demanda"]
|
||||
traffic_factor = 1.0 + (traffic_volume / 1000.0 - 1.0) * traffic_correlation
|
||||
|
||||
# Calculate predicted demand
|
||||
predicted = base_demand * weekly_factor * seasonality_factor * weather_factor * traffic_factor
|
||||
|
||||
# Add randomness based on variability
|
||||
variability = product["variabilidad"]
|
||||
predicted = predicted * random.uniform(1.0 - variability, 1.0 + variability)
|
||||
|
||||
return max(0.0, predicted)
|
||||
|
||||
|
||||
async def generate_forecasts_for_tenant(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
tenant_name: str,
|
||||
business_type: str,
|
||||
config: dict
|
||||
):
|
||||
"""Generate forecasts for a specific tenant"""
|
||||
logger.info(f"Generating forecasts for: {tenant_name}", tenant_id=str(tenant_id))
|
||||
|
||||
# Check if forecasts already exist
|
||||
result = await db.execute(
|
||||
select(Forecast).where(Forecast.tenant_id == tenant_id).limit(1)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(f"Forecasts already exist for {tenant_name}, skipping seed")
|
||||
return {"tenant_id": str(tenant_id), "forecasts_created": 0, "batches_created": 0, "skipped": True}
|
||||
|
||||
forecast_config = config["configuracion_previsiones"]
|
||||
batches_config = config["lotes_prediccion"]
|
||||
|
||||
# Get location for this business type
|
||||
location = forecast_config["ubicaciones"][business_type]
|
||||
|
||||
# Get multiplier for central bakery
|
||||
multiplier = forecast_config["multiplicador_central_bakery"] if business_type == "central_bakery" else 1.0
|
||||
|
||||
forecasts_created = 0
|
||||
batches_created = 0
|
||||
|
||||
# Generate prediction batches first
|
||||
num_batches = batches_config["lotes_por_tenant"]
|
||||
|
||||
for batch_idx in range(num_batches):
|
||||
# Select batch status
|
||||
status_rand = random.random()
|
||||
cumulative = 0
|
||||
batch_status = "completed"
|
||||
for status, weight in batches_config["distribucion_estados"].items():
|
||||
cumulative += weight
|
||||
if status_rand <= cumulative:
|
||||
batch_status = status
|
||||
break
|
||||
|
||||
# Select forecast days
|
||||
forecast_days = random.choice(batches_config["dias_prevision_lotes"])
|
||||
|
||||
# Create batch at different times in the past
|
||||
requested_offset = -(batch_idx + 1) * 10 # Batches every 10 days in the past
|
||||
requested_at = calculate_datetime_from_offset(requested_offset)
|
||||
|
||||
completed_at = None
|
||||
processing_time = None
|
||||
if batch_status == "completed":
|
||||
processing_time = random.randint(5000, 25000) # 5-25 seconds
|
||||
completed_at = requested_at + timedelta(milliseconds=processing_time)
|
||||
|
||||
batch = PredictionBatch(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
batch_name=f"Previsión {forecast_days} días - {requested_at.strftime('%Y%m%d')}",
|
||||
requested_at=requested_at,
|
||||
completed_at=completed_at,
|
||||
status=batch_status,
|
||||
total_products=forecast_config["productos_por_tenant"],
|
||||
completed_products=forecast_config["productos_por_tenant"] if batch_status == "completed" else 0,
|
||||
failed_products=0 if batch_status != "failed" else random.randint(1, 3),
|
||||
forecast_days=forecast_days,
|
||||
business_type=business_type,
|
||||
error_message="Error de conexión con servicio de clima" if batch_status == "failed" else None,
|
||||
processing_time_ms=processing_time
|
||||
)
|
||||
|
||||
db.add(batch)
|
||||
batches_created += 1
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Generate historical forecasts (past 30 days)
|
||||
dias_historico = forecast_config["dias_historico"]
|
||||
|
||||
for product in forecast_config["productos_demo"]:
|
||||
product_id = uuid.UUID(product["id"])
|
||||
product_name = product["nombre"]
|
||||
|
||||
for day_offset in range(-dias_historico, 0):
|
||||
forecast_date = calculate_datetime_from_offset(day_offset)
|
||||
day_of_week = forecast_date.weekday()
|
||||
is_weekend = day_of_week >= 5
|
||||
|
||||
# Generate weather data
|
||||
weather_temp = random.uniform(
|
||||
forecast_config["factores_externos"]["temperatura"]["min"],
|
||||
forecast_config["factores_externos"]["temperatura"]["max"]
|
||||
)
|
||||
weather_precip = 0.0
|
||||
if random.random() < forecast_config["factores_externos"]["precipitacion"]["probabilidad_lluvia"]:
|
||||
weather_precip = random.uniform(0.5, forecast_config["factores_externos"]["precipitacion"]["mm_promedio"])
|
||||
|
||||
weather_descriptions = ["Despejado", "Parcialmente nublado", "Nublado", "Lluvia ligera", "Lluvia"]
|
||||
weather_desc = random.choice(weather_descriptions)
|
||||
|
||||
# Traffic volume
|
||||
traffic_volume = random.randint(
|
||||
forecast_config["factores_externos"]["volumen_trafico"]["min"],
|
||||
forecast_config["factores_externos"]["volumen_trafico"]["max"]
|
||||
)
|
||||
|
||||
# Calculate demand
|
||||
predicted_demand = calculate_demand(
|
||||
product, day_of_week, is_weekend,
|
||||
weather_temp, weather_precip, traffic_volume, config
|
||||
)
|
||||
|
||||
# Apply multiplier for central bakery
|
||||
predicted_demand *= multiplier
|
||||
|
||||
# Calculate confidence intervals
|
||||
lower_pct = forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["inferior"] / 100.0
|
||||
upper_pct = forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["superior"] / 100.0
|
||||
|
||||
confidence_lower = predicted_demand * (1.0 - lower_pct)
|
||||
confidence_upper = predicted_demand * (1.0 + upper_pct)
|
||||
|
||||
# Select algorithm
|
||||
algorithm_choice = weighted_choice(forecast_config["algoritmos"])
|
||||
algorithm = algorithm_choice["algoritmo"]
|
||||
|
||||
# Processing time
|
||||
processing_time = random.randint(
|
||||
forecast_config["tiempo_procesamiento_ms"]["min"],
|
||||
forecast_config["tiempo_procesamiento_ms"]["max"]
|
||||
)
|
||||
|
||||
# Model info
|
||||
model_version = f"v{random.randint(1, 3)}.{random.randint(0, 9)}"
|
||||
model_id = f"{algorithm}_{business_type}_{model_version}"
|
||||
|
||||
# Create forecast
|
||||
forecast = Forecast(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=product_id,
|
||||
product_name=product_name,
|
||||
location=location,
|
||||
forecast_date=forecast_date,
|
||||
created_at=forecast_date - timedelta(days=1), # Created day before
|
||||
predicted_demand=predicted_demand,
|
||||
confidence_lower=confidence_lower,
|
||||
confidence_upper=confidence_upper,
|
||||
confidence_level=forecast_config["nivel_confianza"],
|
||||
model_id=model_id,
|
||||
model_version=model_version,
|
||||
algorithm=algorithm,
|
||||
business_type=business_type,
|
||||
day_of_week=day_of_week,
|
||||
is_holiday=False, # Could add holiday logic
|
||||
is_weekend=is_weekend,
|
||||
weather_temperature=weather_temp,
|
||||
weather_precipitation=weather_precip,
|
||||
weather_description=weather_desc,
|
||||
traffic_volume=traffic_volume,
|
||||
processing_time_ms=processing_time,
|
||||
features_used={
|
||||
"day_of_week": True,
|
||||
"weather": True,
|
||||
"traffic": True,
|
||||
"historical_demand": True,
|
||||
"seasonality": True
|
||||
}
|
||||
)
|
||||
|
||||
db.add(forecast)
|
||||
forecasts_created += 1
|
||||
|
||||
# Generate future forecasts (next 14 days)
|
||||
dias_futuro = forecast_config["dias_prevision_futuro"]
|
||||
|
||||
for product in forecast_config["productos_demo"]:
|
||||
product_id = uuid.UUID(product["id"])
|
||||
product_name = product["nombre"]
|
||||
|
||||
for day_offset in range(1, dias_futuro + 1):
|
||||
forecast_date = calculate_datetime_from_offset(day_offset)
|
||||
day_of_week = forecast_date.weekday()
|
||||
is_weekend = day_of_week >= 5
|
||||
|
||||
# Generate weather forecast data (slightly less certain)
|
||||
weather_temp = random.uniform(
|
||||
forecast_config["factores_externos"]["temperatura"]["min"],
|
||||
forecast_config["factores_externos"]["temperatura"]["max"]
|
||||
)
|
||||
weather_precip = 0.0
|
||||
if random.random() < forecast_config["factores_externos"]["precipitacion"]["probabilidad_lluvia"]:
|
||||
weather_precip = random.uniform(0.5, forecast_config["factores_externos"]["precipitacion"]["mm_promedio"])
|
||||
|
||||
weather_desc = random.choice(["Despejado", "Parcialmente nublado", "Nublado"])
|
||||
|
||||
traffic_volume = random.randint(
|
||||
forecast_config["factores_externos"]["volumen_trafico"]["min"],
|
||||
forecast_config["factores_externos"]["volumen_trafico"]["max"]
|
||||
)
|
||||
|
||||
# Calculate demand
|
||||
predicted_demand = calculate_demand(
|
||||
product, day_of_week, is_weekend,
|
||||
weather_temp, weather_precip, traffic_volume, config
|
||||
)
|
||||
|
||||
predicted_demand *= multiplier
|
||||
|
||||
# Wider confidence intervals for future predictions
|
||||
lower_pct = (forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["inferior"] + 5.0) / 100.0
|
||||
upper_pct = (forecast_config["precision_modelo"]["intervalo_confianza_porcentaje"]["superior"] + 5.0) / 100.0
|
||||
|
||||
confidence_lower = predicted_demand * (1.0 - lower_pct)
|
||||
confidence_upper = predicted_demand * (1.0 + upper_pct)
|
||||
|
||||
algorithm_choice = weighted_choice(forecast_config["algoritmos"])
|
||||
algorithm = algorithm_choice["algoritmo"]
|
||||
|
||||
processing_time = random.randint(
|
||||
forecast_config["tiempo_procesamiento_ms"]["min"],
|
||||
forecast_config["tiempo_procesamiento_ms"]["max"]
|
||||
)
|
||||
|
||||
model_version = f"v{random.randint(1, 3)}.{random.randint(0, 9)}"
|
||||
model_id = f"{algorithm}_{business_type}_{model_version}"
|
||||
|
||||
forecast = Forecast(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=product_id,
|
||||
product_name=product_name,
|
||||
location=location,
|
||||
forecast_date=forecast_date,
|
||||
created_at=BASE_REFERENCE_DATE, # Created today
|
||||
predicted_demand=predicted_demand,
|
||||
confidence_lower=confidence_lower,
|
||||
confidence_upper=confidence_upper,
|
||||
confidence_level=forecast_config["nivel_confianza"],
|
||||
model_id=model_id,
|
||||
model_version=model_version,
|
||||
algorithm=algorithm,
|
||||
business_type=business_type,
|
||||
day_of_week=day_of_week,
|
||||
is_holiday=False,
|
||||
is_weekend=is_weekend,
|
||||
weather_temperature=weather_temp,
|
||||
weather_precipitation=weather_precip,
|
||||
weather_description=weather_desc,
|
||||
traffic_volume=traffic_volume,
|
||||
processing_time_ms=processing_time,
|
||||
features_used={
|
||||
"day_of_week": True,
|
||||
"weather": True,
|
||||
"traffic": True,
|
||||
"historical_demand": True,
|
||||
"seasonality": True
|
||||
}
|
||||
)
|
||||
|
||||
db.add(forecast)
|
||||
forecasts_created += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Successfully created {forecasts_created} forecasts and {batches_created} batches for {tenant_name}")
|
||||
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"forecasts_created": forecasts_created,
|
||||
"batches_created": batches_created,
|
||||
"skipped": False
|
||||
}
|
||||
|
||||
|
||||
async def seed_all(db: AsyncSession):
|
||||
"""Seed all demo tenants with forecasting data"""
|
||||
logger.info("Starting demo forecasting seed process")
|
||||
|
||||
# Load configuration
|
||||
config = load_forecasting_config()
|
||||
|
||||
results = []
|
||||
|
||||
# Seed San Pablo (Individual Bakery)
|
||||
# Seed Professional Bakery (merged from San Pablo + La Espiga)
|
||||
result_professional = await generate_forecasts_for_tenant(
|
||||
db,
|
||||
DEMO_TENANT_PROFESSIONAL,
|
||||
"Professional Bakery",
|
||||
"individual_bakery",
|
||||
config
|
||||
)
|
||||
results.append(result_professional)
|
||||
|
||||
total_forecasts = sum(r["forecasts_created"] for r in results)
|
||||
total_batches = sum(r["batches_created"] for r in results)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"total_forecasts_created": total_forecasts,
|
||||
"total_batches_created": total_batches,
|
||||
"status": "completed"
|
||||
}
|
||||
|
||||
|
||||
def validate_base_reference_date():
|
||||
"""Ensure BASE_REFERENCE_DATE hasn't changed since last seed"""
|
||||
expected_date = datetime(2025, 1, 8, 6, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
if BASE_REFERENCE_DATE != expected_date:
|
||||
logger.warning(
|
||||
"BASE_REFERENCE_DATE has changed! This may cause date inconsistencies.",
|
||||
current=BASE_REFERENCE_DATE.isoformat(),
|
||||
expected=expected_date.isoformat()
|
||||
)
|
||||
# Don't fail - just warn. Allow intentional changes.
|
||||
|
||||
logger.info("BASE_REFERENCE_DATE validation", date=BASE_REFERENCE_DATE.isoformat())
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main execution function"""
|
||||
validate_base_reference_date() # Add this line
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("FORECASTING_DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("FORECASTING_DATABASE_URL environment variable must be set")
|
||||
return 1
|
||||
|
||||
# Ensure asyncpg driver
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(database_url, echo=False)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await seed_all(session)
|
||||
|
||||
logger.info(
|
||||
"Forecasting seed completed successfully!",
|
||||
total_forecasts=result["total_forecasts_created"],
|
||||
total_batches=result["total_batches_created"],
|
||||
status=result["status"]
|
||||
)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "="*60)
|
||||
print("DEMO FORECASTING SEED SUMMARY")
|
||||
print("="*60)
|
||||
for tenant_result in result["results"]:
|
||||
tenant_id = tenant_result["tenant_id"]
|
||||
forecasts = tenant_result["forecasts_created"]
|
||||
batches = tenant_result["batches_created"]
|
||||
skipped = tenant_result.get("skipped", False)
|
||||
status = "SKIPPED (already exists)" if skipped else f"CREATED {forecasts} forecasts, {batches} batches"
|
||||
print(f"Tenant {tenant_id}: {status}")
|
||||
print(f"\nTotal Forecasts: {result['total_forecasts_created']}")
|
||||
print(f"Total Batches: {result['total_batches_created']}")
|
||||
print("="*60 + "\n")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Forecasting seed failed: {str(e)}", exc_info=True)
|
||||
return 1
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -1,167 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Retail Forecasting Seeding Script for Forecasting Service
|
||||
Creates store-level demand forecasts for child retail outlets
|
||||
|
||||
This script populates child retail tenants with AI-generated demand forecasts.
|
||||
|
||||
Usage:
|
||||
python /app/scripts/demo/seed_demo_forecasts_retail.py
|
||||
|
||||
Environment Variables Required:
|
||||
FORECASTING_DATABASE_URL - PostgreSQL connection string
|
||||
DEMO_MODE - Set to 'production' for production seeding
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
# Add shared to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
from app.models import Forecast, PredictionBatch
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer()
|
||||
]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Fixed Demo Tenant IDs
|
||||
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") # Madrid Centro
|
||||
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") # Barcelona Gràcia
|
||||
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") # Valencia Ruzafa
|
||||
|
||||
# Product IDs
|
||||
PRODUCT_IDS = {
|
||||
"PRO-BAG-001": "20000000-0000-0000-0000-000000000001",
|
||||
"PRO-CRO-001": "20000000-0000-0000-0000-000000000002",
|
||||
"PRO-PUE-001": "20000000-0000-0000-0000-000000000003",
|
||||
"PRO-NAP-001": "20000000-0000-0000-0000-000000000004",
|
||||
}
|
||||
|
||||
# Retail forecasting patterns
|
||||
RETAIL_FORECASTS = [
|
||||
(DEMO_TENANT_CHILD_1, "Madrid Centro", {"PRO-BAG-001": 120, "PRO-CRO-001": 80, "PRO-PUE-001": 35, "PRO-NAP-001": 60}),
|
||||
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia", {"PRO-BAG-001": 90, "PRO-CRO-001": 60, "PRO-PUE-001": 25, "PRO-NAP-001": 45}),
|
||||
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa", {"PRO-BAG-001": 70, "PRO-CRO-001": 45, "PRO-PUE-001": 20, "PRO-NAP-001": 35})
|
||||
]
|
||||
|
||||
|
||||
async def seed_forecasts_for_retail_tenant(db: AsyncSession, tenant_id: uuid.UUID, tenant_name: str, base_forecasts: dict):
|
||||
"""Seed forecasts for a retail tenant"""
|
||||
logger.info(f"Seeding forecasts for: {tenant_name}", tenant_id=str(tenant_id))
|
||||
|
||||
created = 0
|
||||
# Create 7 days of forecasts
|
||||
for days_ahead in range(1, 8):
|
||||
forecast_date = BASE_REFERENCE_DATE + timedelta(days=days_ahead)
|
||||
|
||||
for sku, base_qty in base_forecasts.items():
|
||||
base_product_id = uuid.UUID(PRODUCT_IDS[sku])
|
||||
tenant_int = int(tenant_id.hex, 16)
|
||||
product_id = uuid.UUID(int=tenant_int ^ int(base_product_id.hex, 16))
|
||||
|
||||
# Weekend boost
|
||||
is_weekend = forecast_date.weekday() in [5, 6]
|
||||
day_of_week = forecast_date.weekday()
|
||||
multiplier = random.uniform(1.3, 1.5) if is_weekend else random.uniform(0.9, 1.1)
|
||||
forecasted_quantity = int(base_qty * multiplier)
|
||||
|
||||
forecast = Forecast(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
inventory_product_id=product_id,
|
||||
product_name=sku,
|
||||
location=tenant_name,
|
||||
forecast_date=forecast_date,
|
||||
created_at=BASE_REFERENCE_DATE,
|
||||
predicted_demand=float(forecasted_quantity),
|
||||
confidence_lower=float(int(forecasted_quantity * 0.85)),
|
||||
confidence_upper=float(int(forecasted_quantity * 1.15)),
|
||||
confidence_level=0.90,
|
||||
model_id="retail_forecast_model",
|
||||
model_version="retail_v1.0",
|
||||
algorithm="prophet_retail",
|
||||
business_type="retail_outlet",
|
||||
day_of_week=day_of_week,
|
||||
is_holiday=False,
|
||||
is_weekend=is_weekend,
|
||||
weather_temperature=random.uniform(10.0, 25.0),
|
||||
weather_precipitation=random.uniform(0.0, 5.0) if random.random() < 0.3 else 0.0,
|
||||
weather_description="Clear" if random.random() > 0.3 else "Rainy",
|
||||
traffic_volume=random.randint(50, 200) if is_weekend else random.randint(30, 120),
|
||||
processing_time_ms=random.randint(50, 200),
|
||||
features_used={"historical_sales": True, "weather": True, "day_of_week": True}
|
||||
)
|
||||
|
||||
db.add(forecast)
|
||||
created += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Created {created} forecasts for {tenant_name}")
|
||||
return {"tenant_id": str(tenant_id), "forecasts_created": created}
|
||||
|
||||
|
||||
async def seed_all(db: AsyncSession):
|
||||
"""Seed all retail forecasts"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("📈 Starting Demo Retail Forecasting Seeding")
|
||||
logger.info("=" * 80)
|
||||
|
||||
results = []
|
||||
for tenant_id, tenant_name, base_forecasts in RETAIL_FORECASTS:
|
||||
result = await seed_forecasts_for_retail_tenant(db, tenant_id, f"{tenant_name} (Retail)", base_forecasts)
|
||||
results.append(result)
|
||||
|
||||
total = sum(r["forecasts_created"] for r in results)
|
||||
logger.info(f"✅ Total forecasts created: {total}")
|
||||
return {"total_forecasts": total, "results": results}
|
||||
|
||||
|
||||
async def main():
|
||||
database_url = os.getenv("FORECASTING_DATABASE_URL") or os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("❌ DATABASE_URL not set")
|
||||
return 1
|
||||
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(database_url, echo=False, pool_pre_ping=True)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
await seed_all(session)
|
||||
logger.info("🎉 Retail forecasting seed completed!")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Seed failed: {e}", exc_info=True)
|
||||
return 1
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
87
services/inventory/app/api/internal_alert_trigger.py
Normal file
87
services/inventory/app/api/internal_alert_trigger.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# services/inventory/app/api/internal_alert_trigger.py
|
||||
"""
|
||||
Internal API for triggering inventory alerts.
|
||||
Used by demo session cloning to generate realistic inventory alerts.
|
||||
|
||||
URL Pattern: /api/v1/tenants/{tenant_id}/inventory/internal/alerts/trigger
|
||||
This follows the tenant-scoped pattern so gateway can proxy correctly.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Path
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# New URL pattern: tenant-scoped so gateway proxies to inventory service correctly
|
||||
@router.post("/api/v1/tenants/{tenant_id}/inventory/internal/alerts/trigger")
|
||||
async def trigger_inventory_alerts(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID to check inventory for"),
|
||||
request: Request = None
|
||||
) -> dict:
|
||||
"""
|
||||
Trigger comprehensive inventory alert checks for a specific tenant (internal use only).
|
||||
|
||||
This endpoint is called by the demo session cloning process after inventory
|
||||
data is seeded to generate realistic inventory alerts including:
|
||||
- Critical stock shortages
|
||||
- Expiring ingredients
|
||||
- Overstock situations
|
||||
|
||||
Security: Protected by X-Internal-Service header check.
|
||||
"""
|
||||
try:
|
||||
# Verify internal service header
|
||||
if not request or request.headers.get("X-Internal-Service") not in ["demo-session", "internal"]:
|
||||
logger.warning("Unauthorized internal API call", tenant_id=str(tenant_id))
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This endpoint is for internal service use only"
|
||||
)
|
||||
|
||||
# Get inventory scheduler from app state
|
||||
inventory_scheduler = getattr(request.app.state, 'inventory_scheduler', None)
|
||||
|
||||
if not inventory_scheduler:
|
||||
logger.error("Inventory scheduler not initialized")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Inventory scheduler not available"
|
||||
)
|
||||
|
||||
# Trigger comprehensive inventory alert checks for the specific tenant
|
||||
logger.info("Triggering comprehensive inventory alert checks", tenant_id=str(tenant_id))
|
||||
|
||||
# Call the scheduler's manual trigger method
|
||||
result = await inventory_scheduler.trigger_manual_check(tenant_id)
|
||||
|
||||
if result.get("success", False):
|
||||
logger.info(
|
||||
"Inventory alert checks completed successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
alerts_generated=result.get("alerts_generated", 0)
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Inventory alert checks failed",
|
||||
tenant_id=str(tenant_id),
|
||||
error=result.get("error", "Unknown error")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error triggering inventory alerts",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to trigger inventory alerts: {str(e)}"
|
||||
)
|
||||
@@ -1,44 +1,37 @@
|
||||
"""
|
||||
Internal Demo Cloning API for Inventory Service
|
||||
Service-to-service endpoint for cloning inventory data with date adjustment
|
||||
Handles internal demo data cloning operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
import structlog
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
import os
|
||||
import sys
|
||||
import structlog
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Add shared path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.inventory import Ingredient, Stock, StockMovement
|
||||
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
|
||||
from app.core.config import settings
|
||||
from app.models import Ingredient, Stock, ProductType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(prefix="/internal/demo", tags=["internal"])
|
||||
|
||||
# Base demo tenant IDs
|
||||
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
|
||||
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
from app.core.config import settings
|
||||
if x_internal_api_key != settings.INTERNAL_API_KEY:
|
||||
required_key = settings.INTERNAL_API_KEY
|
||||
if x_internal_api_key != required_key:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/clone")
|
||||
async def clone_demo_data(
|
||||
@router.post("/internal/demo/clone")
|
||||
async def clone_demo_data_internal(
|
||||
base_tenant_id: str,
|
||||
virtual_tenant_id: str,
|
||||
demo_account_type: str,
|
||||
@@ -50,350 +43,346 @@ async def clone_demo_data(
|
||||
"""
|
||||
Clone inventory service data for a virtual demo tenant
|
||||
|
||||
Clones:
|
||||
- Ingredients from template tenant
|
||||
- Stock batches with date-adjusted expiration dates
|
||||
- Generates inventory alerts based on stock status
|
||||
This endpoint creates fresh demo data by:
|
||||
1. Loading seed data from JSON files
|
||||
2. Applying XOR-based ID transformation
|
||||
3. Adjusting dates relative to session creation time
|
||||
4. Creating records in the virtual tenant
|
||||
|
||||
Args:
|
||||
base_tenant_id: Template tenant UUID to clone from
|
||||
base_tenant_id: Template tenant UUID (for reference)
|
||||
virtual_tenant_id: Target virtual tenant UUID
|
||||
demo_account_type: Type of demo account
|
||||
session_id: Originating session ID for tracing
|
||||
session_created_at: ISO timestamp when demo session was created (for date adjustment)
|
||||
session_created_at: Session creation timestamp for date adjustment
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Cloning status and record counts
|
||||
Dictionary with cloning results
|
||||
|
||||
Raises:
|
||||
HTTPException: On validation or cloning errors
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Parse session_created_at or fallback to now
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning(
|
||||
"Invalid session_created_at format, using current time",
|
||||
session_created_at=session_created_at,
|
||||
error=str(e)
|
||||
)
|
||||
session_time = datetime.now(timezone.utc)
|
||||
else:
|
||||
logger.warning("session_created_at not provided, using current time")
|
||||
session_time = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
"Starting inventory data cloning with date adjustment",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
demo_account_type=demo_account_type,
|
||||
session_id=session_id,
|
||||
session_time=session_time.isoformat()
|
||||
)
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# Validate UUIDs
|
||||
base_uuid = uuid.UUID(base_tenant_id)
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
virtual_uuid = UUID(virtual_tenant_id)
|
||||
|
||||
# Parse session creation time for date adjustment
|
||||
if session_created_at:
|
||||
try:
|
||||
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
session_time = start_time
|
||||
else:
|
||||
session_time = start_time
|
||||
|
||||
# Debug logging for UUID values
|
||||
logger.debug("Received UUID values", base_tenant_id=base_tenant_id, virtual_tenant_id=virtual_tenant_id)
|
||||
|
||||
if not all([base_tenant_id, virtual_tenant_id, session_id]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Missing required parameters: base_tenant_id, virtual_tenant_id, session_id"
|
||||
)
|
||||
|
||||
# Validate UUID format before processing
|
||||
try:
|
||||
UUID(base_tenant_id)
|
||||
UUID(virtual_tenant_id)
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format in request",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid UUID format: {str(e)}"
|
||||
)
|
||||
|
||||
# Parse session creation time
|
||||
if session_created_at:
|
||||
try:
|
||||
session_created_at_parsed = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
session_created_at_parsed = datetime.now()
|
||||
else:
|
||||
session_created_at_parsed = datetime.now()
|
||||
|
||||
# Determine profile based on demo_account_type
|
||||
if demo_account_type == "enterprise":
|
||||
profile = "enterprise"
|
||||
else:
|
||||
profile = "professional"
|
||||
|
||||
logger.info(
|
||||
"Starting inventory data cloning with date adjustment",
|
||||
base_tenant_id=base_tenant_id,
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
demo_account_type=demo_account_type,
|
||||
session_id=session_id,
|
||||
session_time=session_created_at_parsed.isoformat()
|
||||
)
|
||||
|
||||
# Load seed data using shared utility
|
||||
try:
|
||||
from shared.utils.seed_data_paths import get_seed_data_path
|
||||
|
||||
if profile == "professional":
|
||||
json_file = get_seed_data_path("professional", "03-inventory.json")
|
||||
elif profile == "enterprise":
|
||||
json_file = get_seed_data_path("enterprise", "03-inventory.json")
|
||||
else:
|
||||
raise ValueError(f"Invalid profile: {profile}")
|
||||
|
||||
except ImportError:
|
||||
# Fallback to original path
|
||||
seed_data_dir = Path(__file__).parent.parent.parent.parent / "infrastructure" / "seed-data"
|
||||
if profile == "professional":
|
||||
json_file = seed_data_dir / "professional" / "03-inventory.json"
|
||||
elif profile == "enterprise":
|
||||
json_file = seed_data_dir / "enterprise" / "parent" / "03-inventory.json"
|
||||
else:
|
||||
raise ValueError(f"Invalid profile: {profile}")
|
||||
|
||||
if not json_file.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Seed data file not found: {json_file}"
|
||||
)
|
||||
|
||||
# Load JSON data
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
seed_data = json.load(f)
|
||||
|
||||
# Check if data already exists for this virtual tenant (idempotency)
|
||||
from sqlalchemy import select, delete
|
||||
existing_check = await db.execute(
|
||||
select(Ingredient).where(Ingredient.tenant_id == virtual_uuid).limit(1)
|
||||
select(Ingredient).where(Ingredient.tenant_id == virtual_tenant_id).limit(1)
|
||||
)
|
||||
existing_ingredient = existing_check.scalars().first()
|
||||
existing_ingredient = existing_check.scalar_one_or_none()
|
||||
|
||||
if existing_ingredient:
|
||||
logger.warning(
|
||||
"Data already exists for virtual tenant - cleaning before re-clone",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
base_tenant_id=base_tenant_id
|
||||
)
|
||||
# Clean up existing data first to ensure fresh clone
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(
|
||||
delete(StockMovement).where(StockMovement.tenant_id == virtual_uuid)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Stock).where(Stock.tenant_id == virtual_uuid)
|
||||
)
|
||||
await db.execute(
|
||||
delete(Ingredient).where(Ingredient.tenant_id == virtual_uuid)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Existing data cleaned, proceeding with fresh clone",
|
||||
"Demo data already exists, skipping clone",
|
||||
virtual_tenant_id=virtual_tenant_id
|
||||
)
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "Data already exists",
|
||||
"records_cloned": 0
|
||||
}
|
||||
|
||||
# Track cloning statistics
|
||||
stats = {
|
||||
"ingredients": 0,
|
||||
"stock_batches": 0,
|
||||
"stock_movements": 0,
|
||||
"alerts_generated": 0
|
||||
}
|
||||
# Transform and insert data
|
||||
records_cloned = 0
|
||||
|
||||
# Mapping from base ingredient ID to virtual ingredient ID
|
||||
ingredient_id_mapping = {}
|
||||
# Mapping from base stock ID to virtual stock ID
|
||||
stock_id_mapping = {}
|
||||
|
||||
# Clone Ingredients
|
||||
result = await db.execute(
|
||||
select(Ingredient).where(Ingredient.tenant_id == base_uuid)
|
||||
)
|
||||
base_ingredients = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Found ingredients to clone",
|
||||
count=len(base_ingredients),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
|
||||
for ingredient in base_ingredients:
|
||||
# Transform ingredient ID using XOR to ensure consistency across services
|
||||
# This formula matches the suppliers service ID transformation
|
||||
# Formula: virtual_ingredient_id = virtual_tenant_id XOR base_ingredient_id
|
||||
|
||||
base_ingredient_int = int(ingredient.id.hex, 16)
|
||||
virtual_tenant_int = int(virtual_uuid.hex, 16)
|
||||
base_tenant_int = int(base_uuid.hex, 16)
|
||||
|
||||
# Reverse the original XOR to get the base ingredient ID
|
||||
# base_ingredient = base_tenant ^ base_ingredient_id
|
||||
# So: base_ingredient_id = base_tenant ^ base_ingredient
|
||||
base_ingredient_id_int = base_tenant_int ^ base_ingredient_int
|
||||
|
||||
# Now apply virtual tenant XOR to get the new ingredient ID
|
||||
new_ingredient_id = uuid.UUID(int=virtual_tenant_int ^ base_ingredient_id_int)
|
||||
|
||||
logger.debug(
|
||||
"Transforming ingredient ID using XOR",
|
||||
base_ingredient_id=str(ingredient.id),
|
||||
new_ingredient_id=str(new_ingredient_id),
|
||||
ingredient_sku=ingredient.sku,
|
||||
ingredient_name=ingredient.name
|
||||
)
|
||||
|
||||
new_ingredient = Ingredient(
|
||||
id=new_ingredient_id,
|
||||
tenant_id=virtual_uuid,
|
||||
name=ingredient.name,
|
||||
sku=ingredient.sku,
|
||||
barcode=ingredient.barcode,
|
||||
product_type=ingredient.product_type,
|
||||
ingredient_category=ingredient.ingredient_category,
|
||||
product_category=ingredient.product_category,
|
||||
subcategory=ingredient.subcategory,
|
||||
description=ingredient.description,
|
||||
brand=ingredient.brand,
|
||||
unit_of_measure=ingredient.unit_of_measure,
|
||||
package_size=ingredient.package_size,
|
||||
average_cost=ingredient.average_cost,
|
||||
last_purchase_price=ingredient.last_purchase_price,
|
||||
standard_cost=ingredient.standard_cost,
|
||||
low_stock_threshold=ingredient.low_stock_threshold,
|
||||
reorder_point=ingredient.reorder_point,
|
||||
reorder_quantity=ingredient.reorder_quantity,
|
||||
max_stock_level=ingredient.max_stock_level,
|
||||
shelf_life_days=ingredient.shelf_life_days,
|
||||
display_life_hours=ingredient.display_life_hours,
|
||||
best_before_hours=ingredient.best_before_hours,
|
||||
storage_instructions=ingredient.storage_instructions,
|
||||
is_perishable=ingredient.is_perishable,
|
||||
is_active=ingredient.is_active,
|
||||
allergen_info=ingredient.allergen_info,
|
||||
nutritional_info=ingredient.nutritional_info
|
||||
)
|
||||
db.add(new_ingredient)
|
||||
stats["ingredients"] += 1
|
||||
|
||||
# Store mapping for stock cloning
|
||||
ingredient_id_mapping[ingredient.id] = new_ingredient_id
|
||||
|
||||
await db.flush() # Ensure ingredients are persisted before stock
|
||||
|
||||
# Clone Stock batches with date adjustment
|
||||
result = await db.execute(
|
||||
select(Stock).where(Stock.tenant_id == base_uuid)
|
||||
)
|
||||
base_stocks = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Found stock batches to clone",
|
||||
count=len(base_stocks),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
|
||||
for stock in base_stocks:
|
||||
# Map ingredient ID
|
||||
new_ingredient_id = ingredient_id_mapping.get(stock.ingredient_id)
|
||||
if not new_ingredient_id:
|
||||
logger.warning(
|
||||
"Stock references non-existent ingredient, skipping",
|
||||
stock_id=str(stock.id),
|
||||
ingredient_id=str(stock.ingredient_id)
|
||||
# Clone ingredients
|
||||
for ingredient_data in seed_data.get('ingredients', []):
|
||||
# Transform ID
|
||||
from shared.utils.demo_id_transformer import transform_id
|
||||
try:
|
||||
ingredient_uuid = UUID(ingredient_data['id'])
|
||||
tenant_uuid = UUID(virtual_tenant_id)
|
||||
transformed_id = transform_id(ingredient_data['id'], tenant_uuid)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to parse UUIDs for ID transformation",
|
||||
ingredient_id=ingredient_data['id'],
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid UUID format in ingredient data: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Adjust dates relative to session creation
|
||||
adjusted_expiration = adjust_date_for_demo(
|
||||
stock.expiration_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
# Transform dates
|
||||
from shared.utils.demo_dates import adjust_date_for_demo
|
||||
for date_field in ['expiration_date', 'received_date', 'created_at', 'updated_at']:
|
||||
if date_field in ingredient_data:
|
||||
try:
|
||||
date_value = ingredient_data[date_field]
|
||||
# Handle both string dates and date objects
|
||||
if isinstance(date_value, str):
|
||||
original_date = datetime.fromisoformat(date_value)
|
||||
elif hasattr(date_value, 'isoformat'):
|
||||
# Already a date/datetime object
|
||||
original_date = date_value
|
||||
else:
|
||||
# Skip if not a valid date format
|
||||
logger.warning("Skipping invalid date format",
|
||||
date_field=date_field,
|
||||
date_value=date_value)
|
||||
continue
|
||||
|
||||
adjusted_date = adjust_date_for_demo(
|
||||
original_date,
|
||||
session_created_at_parsed
|
||||
)
|
||||
ingredient_data[date_field] = adjusted_date
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning("Failed to parse date, skipping",
|
||||
date_field=date_field,
|
||||
date_value=ingredient_data[date_field],
|
||||
error=str(e))
|
||||
# Remove invalid date to avoid model errors
|
||||
ingredient_data.pop(date_field, None)
|
||||
|
||||
# Map category field to ingredient_category enum
|
||||
if 'category' in ingredient_data:
|
||||
category_value = ingredient_data.pop('category')
|
||||
# Convert category string to IngredientCategory enum
|
||||
from app.models.inventory import IngredientCategory
|
||||
try:
|
||||
ingredient_data['ingredient_category'] = IngredientCategory[category_value.upper()]
|
||||
except KeyError:
|
||||
# If category not found in enum, use OTHER
|
||||
ingredient_data['ingredient_category'] = IngredientCategory.OTHER
|
||||
|
||||
# Map unit_of_measure string to enum
|
||||
if 'unit_of_measure' in ingredient_data:
|
||||
from app.models.inventory import UnitOfMeasure
|
||||
unit_mapping = {
|
||||
'kilograms': UnitOfMeasure.KILOGRAMS,
|
||||
'grams': UnitOfMeasure.GRAMS,
|
||||
'liters': UnitOfMeasure.LITERS,
|
||||
'milliliters': UnitOfMeasure.MILLILITERS,
|
||||
'units': UnitOfMeasure.UNITS,
|
||||
'pieces': UnitOfMeasure.PIECES,
|
||||
'packages': UnitOfMeasure.PACKAGES,
|
||||
'bags': UnitOfMeasure.BAGS,
|
||||
'boxes': UnitOfMeasure.BOXES
|
||||
}
|
||||
|
||||
unit_str = ingredient_data['unit_of_measure']
|
||||
if unit_str in unit_mapping:
|
||||
ingredient_data['unit_of_measure'] = unit_mapping[unit_str]
|
||||
else:
|
||||
# Default to units if not found
|
||||
ingredient_data['unit_of_measure'] = UnitOfMeasure.UNITS
|
||||
logger.warning("Unknown unit_of_measure, defaulting to UNITS",
|
||||
original_unit=unit_str)
|
||||
|
||||
# Note: All seed data fields now match the model schema exactly
|
||||
# No field filtering needed
|
||||
|
||||
# Remove original id and tenant_id from ingredient_data to avoid conflict
|
||||
ingredient_data.pop('id', None)
|
||||
ingredient_data.pop('tenant_id', None)
|
||||
|
||||
# Create ingredient
|
||||
ingredient = Ingredient(
|
||||
id=str(transformed_id),
|
||||
tenant_id=str(virtual_tenant_id),
|
||||
**ingredient_data
|
||||
)
|
||||
adjusted_received = adjust_date_for_demo(
|
||||
stock.received_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
db.add(ingredient)
|
||||
records_cloned += 1
|
||||
|
||||
# Clone stock batches
|
||||
for stock_data in seed_data.get('stock_batches', []):
|
||||
# Transform ID - handle both UUID and string IDs
|
||||
from shared.utils.demo_id_transformer import transform_id
|
||||
try:
|
||||
# Try to parse as UUID first
|
||||
stock_uuid = UUID(stock_data['id'])
|
||||
tenant_uuid = UUID(virtual_tenant_id)
|
||||
transformed_id = transform_id(stock_data['id'], tenant_uuid)
|
||||
except ValueError:
|
||||
# If not a UUID, generate a deterministic UUID from the string ID
|
||||
import hashlib
|
||||
stock_id_string = stock_data['id']
|
||||
tenant_uuid = UUID(virtual_tenant_id)
|
||||
|
||||
# Create a deterministic UUID from the string ID and tenant ID
|
||||
combined = f"{stock_id_string}-{tenant_uuid}"
|
||||
hash_obj = hashlib.sha256(combined.encode('utf-8'))
|
||||
transformed_id = UUID(hash_obj.hexdigest()[:32])
|
||||
|
||||
logger.info("Generated UUID for non-UUID stock ID",
|
||||
original_id=stock_id_string,
|
||||
generated_id=str(transformed_id))
|
||||
|
||||
# Transform dates - handle both timestamp dictionaries and ISO strings
|
||||
for date_field in ['received_date', 'expiration_date', 'best_before_date', 'original_expiration_date', 'transformation_date', 'final_expiration_date', 'created_at', 'updated_at']:
|
||||
if date_field in stock_data:
|
||||
try:
|
||||
date_value = stock_data[date_field]
|
||||
|
||||
# Handle timestamp dictionaries (offset_days, hour, minute)
|
||||
if isinstance(date_value, dict) and 'offset_days' in date_value:
|
||||
from shared.utils.demo_dates import calculate_demo_datetime
|
||||
original_date = calculate_demo_datetime(
|
||||
offset_days=date_value.get('offset_days', 0),
|
||||
hour=date_value.get('hour', 0),
|
||||
minute=date_value.get('minute', 0),
|
||||
session_created_at=session_created_at_parsed
|
||||
)
|
||||
elif isinstance(date_value, str):
|
||||
# ISO string
|
||||
original_date = datetime.fromisoformat(date_value)
|
||||
elif hasattr(date_value, 'isoformat'):
|
||||
# Already a date/datetime object
|
||||
original_date = date_value
|
||||
else:
|
||||
# Skip if not a valid date format
|
||||
logger.warning("Skipping invalid date format",
|
||||
date_field=date_field,
|
||||
date_value=date_value)
|
||||
continue
|
||||
|
||||
adjusted_stock_date = adjust_date_for_demo(
|
||||
original_date,
|
||||
session_created_at_parsed
|
||||
)
|
||||
stock_data[date_field] = adjusted_stock_date
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.warning("Failed to parse date, skipping",
|
||||
date_field=date_field,
|
||||
date_value=stock_data[date_field],
|
||||
error=str(e))
|
||||
# Remove invalid date to avoid model errors
|
||||
stock_data.pop(date_field, None)
|
||||
|
||||
# Remove original id and tenant_id from stock_data to avoid conflict
|
||||
stock_data.pop('id', None)
|
||||
stock_data.pop('tenant_id', None)
|
||||
|
||||
# Create stock batch
|
||||
stock = Stock(
|
||||
id=str(transformed_id),
|
||||
tenant_id=str(virtual_tenant_id),
|
||||
**stock_data
|
||||
)
|
||||
adjusted_best_before = adjust_date_for_demo(
|
||||
stock.best_before_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
)
|
||||
adjusted_created = adjust_date_for_demo(
|
||||
stock.created_at,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) or session_time
|
||||
db.add(stock)
|
||||
records_cloned += 1
|
||||
|
||||
# Create new stock batch with new ID
|
||||
new_stock_id = uuid.uuid4()
|
||||
|
||||
new_stock = Stock(
|
||||
id=new_stock_id,
|
||||
tenant_id=virtual_uuid,
|
||||
ingredient_id=new_ingredient_id,
|
||||
supplier_id=stock.supplier_id,
|
||||
batch_number=stock.batch_number,
|
||||
lot_number=stock.lot_number,
|
||||
supplier_batch_ref=stock.supplier_batch_ref,
|
||||
production_stage=stock.production_stage,
|
||||
current_quantity=stock.current_quantity,
|
||||
reserved_quantity=stock.reserved_quantity,
|
||||
available_quantity=stock.available_quantity,
|
||||
received_date=adjusted_received,
|
||||
expiration_date=adjusted_expiration,
|
||||
best_before_date=adjusted_best_before,
|
||||
unit_cost=stock.unit_cost,
|
||||
total_cost=stock.total_cost,
|
||||
storage_location=stock.storage_location,
|
||||
warehouse_zone=stock.warehouse_zone,
|
||||
shelf_position=stock.shelf_position,
|
||||
requires_refrigeration=stock.requires_refrigeration,
|
||||
requires_freezing=stock.requires_freezing,
|
||||
storage_temperature_min=stock.storage_temperature_min,
|
||||
storage_temperature_max=stock.storage_temperature_max,
|
||||
storage_humidity_max=stock.storage_humidity_max,
|
||||
shelf_life_days=stock.shelf_life_days,
|
||||
storage_instructions=stock.storage_instructions,
|
||||
is_available=stock.is_available,
|
||||
is_expired=stock.is_expired,
|
||||
quality_status=stock.quality_status,
|
||||
created_at=adjusted_created,
|
||||
updated_at=session_time
|
||||
)
|
||||
db.add(new_stock)
|
||||
stats["stock_batches"] += 1
|
||||
|
||||
# Store mapping for movement cloning
|
||||
stock_id_mapping[stock.id] = new_stock_id
|
||||
|
||||
await db.flush() # Ensure stock is persisted before movements
|
||||
|
||||
# Clone Stock Movements with date adjustment
|
||||
result = await db.execute(
|
||||
select(StockMovement).where(StockMovement.tenant_id == base_uuid)
|
||||
)
|
||||
base_movements = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Found stock movements to clone",
|
||||
count=len(base_movements),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
|
||||
for movement in base_movements:
|
||||
# Map ingredient ID and stock ID
|
||||
new_ingredient_id = ingredient_id_mapping.get(movement.ingredient_id)
|
||||
new_stock_id = stock_id_mapping.get(movement.stock_id) if movement.stock_id else None
|
||||
|
||||
if not new_ingredient_id:
|
||||
logger.warning(
|
||||
"Movement references non-existent ingredient, skipping",
|
||||
movement_id=str(movement.id),
|
||||
ingredient_id=str(movement.ingredient_id)
|
||||
)
|
||||
continue
|
||||
|
||||
# Adjust movement date relative to session creation
|
||||
adjusted_movement_date = adjust_date_for_demo(
|
||||
movement.movement_date,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) or session_time
|
||||
|
||||
adjusted_created_at = adjust_date_for_demo(
|
||||
movement.created_at,
|
||||
session_time,
|
||||
BASE_REFERENCE_DATE
|
||||
) or session_time
|
||||
|
||||
# Create new stock movement
|
||||
new_movement = StockMovement(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=virtual_uuid,
|
||||
ingredient_id=new_ingredient_id,
|
||||
stock_id=new_stock_id,
|
||||
movement_type=movement.movement_type,
|
||||
quantity=movement.quantity,
|
||||
unit_cost=movement.unit_cost,
|
||||
total_cost=movement.total_cost,
|
||||
quantity_before=movement.quantity_before,
|
||||
quantity_after=movement.quantity_after,
|
||||
reference_number=movement.reference_number,
|
||||
supplier_id=movement.supplier_id,
|
||||
notes=movement.notes,
|
||||
reason_code=movement.reason_code,
|
||||
movement_date=adjusted_movement_date,
|
||||
created_at=adjusted_created_at,
|
||||
created_by=movement.created_by
|
||||
)
|
||||
db.add(new_movement)
|
||||
stats["stock_movements"] += 1
|
||||
|
||||
# Commit all changes
|
||||
await db.commit()
|
||||
|
||||
# NOTE: Alert generation removed - alerts are now generated automatically by the
|
||||
# inventory_alert_service which runs scheduled checks every 2-5 minutes.
|
||||
# This eliminates duplicate alerts and provides a more realistic demo experience.
|
||||
stats["alerts_generated"] = 0
|
||||
|
||||
total_records = stats["ingredients"] + stats["stock_batches"]
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
duration_ms = int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Inventory data cloning completed with date adjustment",
|
||||
"Inventory data cloned successfully",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
total_records=total_records,
|
||||
stats=stats,
|
||||
duration_ms=duration_ms
|
||||
records_cloned=records_cloned,
|
||||
duration_ms=duration_ms,
|
||||
ingredients_cloned=len(seed_data.get('ingredients', [])),
|
||||
stock_batches_cloned=len(seed_data.get('stock_batches', []))
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "inventory",
|
||||
"status": "completed",
|
||||
"records_cloned": total_records,
|
||||
"records_cloned": records_cloned,
|
||||
"duration_ms": duration_ms,
|
||||
"details": stats
|
||||
"details": {
|
||||
"ingredients": len(seed_data.get('ingredients', [])),
|
||||
"stock_batches": len(seed_data.get('stock_batches', [])),
|
||||
"virtual_tenant_id": str(virtual_tenant_id)
|
||||
}
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e))
|
||||
logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id)
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
@@ -411,7 +400,7 @@ async def clone_demo_data(
|
||||
"service": "inventory",
|
||||
"status": "failed",
|
||||
"records_cloned": 0,
|
||||
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
|
||||
"duration_ms": int((datetime.now() - start_time).total_seconds() * 1000),
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
@@ -430,101 +419,68 @@ async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
|
||||
|
||||
|
||||
@router.delete("/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_data(
|
||||
virtual_tenant_id: str,
|
||||
async def delete_demo_tenant_data(
|
||||
virtual_tenant_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Delete all inventory data for a virtual demo tenant
|
||||
|
||||
Called by demo session cleanup service to remove ephemeral data
|
||||
when demo sessions expire or are destroyed.
|
||||
|
||||
Args:
|
||||
virtual_tenant_id: Virtual tenant UUID to delete
|
||||
|
||||
Returns:
|
||||
Deletion status and count of records deleted
|
||||
Delete all demo data for a virtual tenant.
|
||||
This endpoint is idempotent - safe to call multiple times.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
start_time = datetime.now()
|
||||
|
||||
logger.info(
|
||||
"Deleting inventory data for virtual tenant",
|
||||
virtual_tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
start_time = datetime.now(timezone.utc)
|
||||
records_deleted = {
|
||||
"ingredients": 0,
|
||||
"stock": 0,
|
||||
"total": 0
|
||||
}
|
||||
|
||||
try:
|
||||
virtual_uuid = uuid.UUID(virtual_tenant_id)
|
||||
# Delete in reverse dependency order
|
||||
|
||||
# Count records before deletion for reporting
|
||||
stock_count = await db.scalar(
|
||||
select(func.count(Stock.id)).where(Stock.tenant_id == virtual_uuid)
|
||||
)
|
||||
ingredient_count = await db.scalar(
|
||||
select(func.count(Ingredient.id)).where(Ingredient.tenant_id == virtual_uuid)
|
||||
)
|
||||
movement_count = await db.scalar(
|
||||
select(func.count(StockMovement.id)).where(StockMovement.tenant_id == virtual_uuid)
|
||||
# 1. Delete stock batches (depends on ingredients)
|
||||
result = await db.execute(
|
||||
delete(Stock)
|
||||
.where(Stock.tenant_id == virtual_tenant_id)
|
||||
)
|
||||
records_deleted["stock"] = result.rowcount
|
||||
|
||||
# Delete in correct order to respect foreign key constraints
|
||||
# 1. Delete StockMovements (references Stock)
|
||||
await db.execute(
|
||||
delete(StockMovement).where(StockMovement.tenant_id == virtual_uuid)
|
||||
# 2. Delete ingredients
|
||||
result = await db.execute(
|
||||
delete(Ingredient)
|
||||
.where(Ingredient.tenant_id == virtual_tenant_id)
|
||||
)
|
||||
records_deleted["ingredients"] = result.rowcount
|
||||
|
||||
# 2. Delete Stock batches (references Ingredient)
|
||||
await db.execute(
|
||||
delete(Stock).where(Stock.tenant_id == virtual_uuid)
|
||||
)
|
||||
|
||||
# 3. Delete Ingredients
|
||||
await db.execute(
|
||||
delete(Ingredient).where(Ingredient.tenant_id == virtual_uuid)
|
||||
)
|
||||
records_deleted["total"] = sum(records_deleted.values())
|
||||
|
||||
await db.commit()
|
||||
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
"Inventory data deleted successfully",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
stocks_deleted=stock_count,
|
||||
ingredients_deleted=ingredient_count,
|
||||
movements_deleted=movement_count,
|
||||
duration_ms=duration_ms
|
||||
"demo_data_deleted",
|
||||
service="inventory",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
records_deleted=records_deleted
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "inventory",
|
||||
"status": "deleted",
|
||||
"virtual_tenant_id": virtual_tenant_id,
|
||||
"records_deleted": {
|
||||
"stock_batches": stock_count,
|
||||
"ingredients": ingredient_count,
|
||||
"stock_movements": movement_count,
|
||||
"total": stock_count + ingredient_count + movement_count
|
||||
},
|
||||
"duration_ms": duration_ms
|
||||
"virtual_tenant_id": str(virtual_tenant_id),
|
||||
"records_deleted": records_deleted,
|
||||
"duration_ms": int((datetime.now() - start_time).total_seconds() * 1000)
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
logger.error("Invalid UUID format", error=str(e))
|
||||
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete inventory data",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"demo_data_deletion_failed",
|
||||
service="inventory",
|
||||
virtual_tenant_id=str(virtual_tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete inventory data: {str(e)}"
|
||||
detail=f"Failed to delete demo data: {str(e)}"
|
||||
)
|
||||
@@ -319,3 +319,89 @@ async def ml_insights_health():
|
||||
"POST /ml/insights/optimize-safety-stock"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# INTERNAL ENDPOINTS (for demo-session service)
|
||||
# ================================================================
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
# Create a separate router for internal endpoints to avoid the tenant prefix
|
||||
internal_router = APIRouter(
|
||||
tags=["ML Insights - Internal"]
|
||||
)
|
||||
|
||||
|
||||
@internal_router.post("/api/v1/tenants/{tenant_id}/inventory/internal/ml/generate-safety-stock-insights")
|
||||
async def generate_safety_stock_insights_internal(
|
||||
tenant_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Internal endpoint to trigger safety stock insights generation for demo sessions.
|
||||
|
||||
This endpoint is called by the demo-session service after cloning data.
|
||||
It uses the same ML logic as the public endpoint but with optimized defaults.
|
||||
|
||||
Security: Protected by X-Internal-Service header check.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant UUID
|
||||
request: FastAPI request object
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
{
|
||||
"insights_posted": int,
|
||||
"tenant_id": str,
|
||||
"status": str
|
||||
}
|
||||
"""
|
||||
# Verify internal service header
|
||||
if not request or request.headers.get("X-Internal-Service") not in ["demo-session", "internal"]:
|
||||
logger.warning("Unauthorized internal API call", tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This endpoint is for internal service use only"
|
||||
)
|
||||
|
||||
logger.info("Internal safety stock insights generation triggered", tenant_id=tenant_id)
|
||||
|
||||
try:
|
||||
# Use the existing safety stock optimization logic with sensible defaults
|
||||
request_data = SafetyStockOptimizationRequest(
|
||||
product_ids=None, # Analyze all products
|
||||
lookback_days=90, # 3 months of history
|
||||
min_history_days=30 # Minimum 30 days required
|
||||
)
|
||||
|
||||
# Call the existing safety stock optimization endpoint logic
|
||||
result = await trigger_safety_stock_optimization(
|
||||
tenant_id=tenant_id,
|
||||
request_data=request_data,
|
||||
db=db
|
||||
)
|
||||
|
||||
# Return simplified response for internal use
|
||||
return {
|
||||
"insights_posted": result.total_insights_posted,
|
||||
"tenant_id": tenant_id,
|
||||
"status": "success" if result.success else "failed",
|
||||
"message": result.message,
|
||||
"products_optimized": result.products_optimized,
|
||||
"total_cost_savings": result.total_cost_savings
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Internal safety stock insights generation failed",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal safety stock insights generation failed: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -11,12 +11,14 @@ from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
from app.core.database import database_manager
|
||||
from app.services.inventory_alert_service import InventoryAlertService
|
||||
from app.services.inventory_scheduler import InventoryScheduler
|
||||
from app.consumers.delivery_event_consumer import DeliveryEventConsumer
|
||||
from shared.service_base import StandardFastAPIService
|
||||
from shared.messaging import UnifiedEventPublisher
|
||||
import asyncio
|
||||
|
||||
from app.api import (
|
||||
internal_demo,
|
||||
batch,
|
||||
ingredients,
|
||||
stock_entries,
|
||||
@@ -29,10 +31,11 @@ from app.api import (
|
||||
dashboard,
|
||||
analytics,
|
||||
sustainability,
|
||||
internal_demo,
|
||||
audit,
|
||||
ml_insights
|
||||
)
|
||||
from app.api.internal_alert_trigger import router as internal_alert_trigger_router
|
||||
from app.api.internal_demo import router as internal_demo_router
|
||||
|
||||
|
||||
class InventoryService(StandardFastAPIService):
|
||||
@@ -115,8 +118,14 @@ class InventoryService(StandardFastAPIService):
|
||||
await alert_service.start()
|
||||
self.logger.info("Inventory alert service started")
|
||||
|
||||
# Store alert service in app state
|
||||
# Initialize inventory scheduler with alert service and database manager
|
||||
inventory_scheduler = InventoryScheduler(alert_service, self.database_manager)
|
||||
await inventory_scheduler.start()
|
||||
self.logger.info("Inventory scheduler started")
|
||||
|
||||
# Store services in app state
|
||||
app.state.alert_service = alert_service
|
||||
app.state.inventory_scheduler = inventory_scheduler # Store scheduler for manual triggering
|
||||
else:
|
||||
self.logger.error("Event publisher not initialized, alert service unavailable")
|
||||
|
||||
@@ -136,6 +145,11 @@ class InventoryService(StandardFastAPIService):
|
||||
|
||||
async def on_shutdown(self, app: FastAPI):
|
||||
"""Custom shutdown logic for inventory service"""
|
||||
# Stop inventory scheduler
|
||||
if hasattr(app.state, 'inventory_scheduler') and app.state.inventory_scheduler:
|
||||
await app.state.inventory_scheduler.stop()
|
||||
self.logger.info("Inventory scheduler stopped")
|
||||
|
||||
# Cancel delivery consumer task
|
||||
if self.delivery_consumer_task and not self.delivery_consumer_task.done():
|
||||
self.delivery_consumer_task.cancel()
|
||||
@@ -198,8 +212,10 @@ service.add_router(food_safety_operations.router)
|
||||
service.add_router(dashboard.router)
|
||||
service.add_router(analytics.router)
|
||||
service.add_router(sustainability.router)
|
||||
service.add_router(internal_demo.router)
|
||||
service.add_router(internal_demo.router, tags=["internal-demo"])
|
||||
service.add_router(ml_insights.router) # ML insights endpoint
|
||||
service.add_router(ml_insights.internal_router) # Internal ML insights endpoint for demo cloning
|
||||
service.add_router(internal_alert_trigger_router) # Internal alert trigger for demo cloning
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -277,3 +277,22 @@ class FoodSafetyRepository:
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate ingredient", error=str(e))
|
||||
raise
|
||||
|
||||
async def mark_temperature_alert_triggered(self, log_id: UUID) -> None:
|
||||
"""
|
||||
Mark a temperature log as having triggered an alert
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
UPDATE temperature_logs
|
||||
SET alert_triggered = true
|
||||
WHERE id = :id
|
||||
""")
|
||||
|
||||
await self.session.execute(query, {"id": log_id})
|
||||
await self.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
logger.error("Failed to mark temperature alert", error=str(e), log_id=str(log_id))
|
||||
raise
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
# services/inventory/app/repositories/inventory_alert_repository.py
|
||||
"""
|
||||
Inventory Alert Repository
|
||||
Data access layer for inventory alert detection and analysis
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from uuid import UUID
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class InventoryAlertRepository:
|
||||
"""Repository for inventory alert data access"""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_stock_issues(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get stock level issues with CTE analysis
|
||||
Returns list of critical, low, and overstock situations
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
WITH stock_analysis AS (
|
||||
SELECT
|
||||
i.id, i.name, i.tenant_id,
|
||||
COALESCE(SUM(s.current_quantity), 0) as current_stock,
|
||||
i.low_stock_threshold as minimum_stock,
|
||||
i.max_stock_level as maximum_stock,
|
||||
i.reorder_point,
|
||||
0 as tomorrow_needed,
|
||||
0 as avg_daily_usage,
|
||||
7 as lead_time_days,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold THEN 'critical'
|
||||
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold * 1.2 THEN 'low'
|
||||
WHEN i.max_stock_level IS NOT NULL AND COALESCE(SUM(s.current_quantity), 0) > i.max_stock_level THEN 'overstock'
|
||||
ELSE 'normal'
|
||||
END as status,
|
||||
GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount
|
||||
FROM ingredients i
|
||||
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||
WHERE i.tenant_id = :tenant_id AND i.is_active = true
|
||||
GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level, i.reorder_point
|
||||
)
|
||||
SELECT * FROM stock_analysis WHERE status != 'normal'
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'low' THEN 2
|
||||
WHEN 'overstock' THEN 3
|
||||
END,
|
||||
shortage_amount DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"tenant_id": tenant_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock issues", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_expiring_products(self, tenant_id: UUID, days_threshold: int = 7) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get products expiring soon or already expired
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
i.id as ingredient_id,
|
||||
i.name as ingredient_name,
|
||||
s.id as stock_id,
|
||||
s.batch_number,
|
||||
s.expiration_date,
|
||||
s.current_quantity,
|
||||
i.unit_of_measure,
|
||||
s.unit_cost,
|
||||
(s.current_quantity * s.unit_cost) as total_value,
|
||||
CASE
|
||||
WHEN s.expiration_date < CURRENT_DATE THEN 'expired'
|
||||
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '1 day' THEN 'expires_today'
|
||||
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '3 days' THEN 'expires_soon'
|
||||
ELSE 'warning'
|
||||
END as urgency,
|
||||
EXTRACT(DAY FROM (s.expiration_date - CURRENT_DATE)) as days_until_expiry
|
||||
FROM stock s
|
||||
JOIN ingredients i ON s.ingredient_id = i.id
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
AND s.is_available = true
|
||||
AND s.expiration_date <= CURRENT_DATE + (INTERVAL '1 day' * :days_threshold)
|
||||
ORDER BY s.expiration_date ASC, total_value DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {
|
||||
"tenant_id": tenant_id,
|
||||
"days_threshold": days_threshold
|
||||
})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get expiring products", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_temperature_breaches(self, tenant_id: UUID, hours_back: int = 24) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get temperature monitoring breaches
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT
|
||||
tl.id,
|
||||
tl.equipment_id,
|
||||
tl.equipment_name,
|
||||
tl.storage_type,
|
||||
tl.temperature_celsius,
|
||||
tl.min_threshold,
|
||||
tl.max_threshold,
|
||||
tl.is_within_range,
|
||||
tl.recorded_at,
|
||||
tl.alert_triggered,
|
||||
EXTRACT(EPOCH FROM (NOW() - tl.recorded_at))/3600 as hours_ago,
|
||||
CASE
|
||||
WHEN tl.temperature_celsius < tl.min_threshold
|
||||
THEN tl.min_threshold - tl.temperature_celsius
|
||||
WHEN tl.temperature_celsius > tl.max_threshold
|
||||
THEN tl.temperature_celsius - tl.max_threshold
|
||||
ELSE 0
|
||||
END as deviation
|
||||
FROM temperature_logs tl
|
||||
WHERE tl.tenant_id = :tenant_id
|
||||
AND tl.is_within_range = false
|
||||
AND tl.recorded_at > NOW() - (INTERVAL '1 hour' * :hours_back)
|
||||
AND tl.alert_triggered = false
|
||||
ORDER BY deviation DESC, tl.recorded_at DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {
|
||||
"tenant_id": tenant_id,
|
||||
"hours_back": hours_back
|
||||
})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get temperature breaches", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def mark_temperature_alert_triggered(self, log_id: UUID) -> None:
|
||||
"""
|
||||
Mark a temperature log as having triggered an alert
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
UPDATE temperature_logs
|
||||
SET alert_triggered = true
|
||||
WHERE id = :id
|
||||
""")
|
||||
|
||||
await self.session.execute(query, {"id": log_id})
|
||||
await self.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to mark temperature alert", error=str(e), log_id=str(log_id))
|
||||
raise
|
||||
|
||||
async def get_waste_opportunities(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Identify waste reduction opportunities
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
WITH waste_analysis AS (
|
||||
SELECT
|
||||
i.id as ingredient_id,
|
||||
i.name as ingredient_name,
|
||||
i.ingredient_category,
|
||||
COUNT(sm.id) as waste_incidents,
|
||||
SUM(sm.quantity) as total_waste_quantity,
|
||||
SUM(sm.total_cost) as total_waste_cost,
|
||||
AVG(sm.quantity) as avg_waste_per_incident,
|
||||
MAX(sm.movement_date) as last_waste_date
|
||||
FROM stock_movements sm
|
||||
JOIN ingredients i ON sm.ingredient_id = i.id
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
AND sm.movement_type = 'WASTE'
|
||||
AND sm.movement_date > NOW() - INTERVAL '30 days'
|
||||
GROUP BY i.id, i.name, i.ingredient_category
|
||||
HAVING COUNT(sm.id) >= 3 OR SUM(sm.total_cost) > 50
|
||||
)
|
||||
SELECT * FROM waste_analysis
|
||||
ORDER BY total_waste_cost DESC, waste_incidents DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"tenant_id": tenant_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get waste opportunities", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_reorder_recommendations(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get ingredients that need reordering based on stock levels and usage
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
WITH usage_analysis AS (
|
||||
SELECT
|
||||
i.id,
|
||||
i.name,
|
||||
COALESCE(SUM(s.current_quantity), 0) as current_stock,
|
||||
i.reorder_point,
|
||||
i.low_stock_threshold,
|
||||
COALESCE(SUM(sm.quantity) FILTER (WHERE sm.movement_date > NOW() - INTERVAL '7 days'), 0) / 7 as daily_usage,
|
||||
i.preferred_supplier_id,
|
||||
i.standard_order_quantity
|
||||
FROM ingredients i
|
||||
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
|
||||
AND sm.movement_type = 'PRODUCTION_USE'
|
||||
AND sm.movement_date > NOW() - INTERVAL '7 days'
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
AND i.is_active = true
|
||||
GROUP BY i.id, i.name, i.reorder_point, i.low_stock_threshold,
|
||||
i.preferred_supplier_id, i.standard_order_quantity
|
||||
)
|
||||
SELECT *,
|
||||
CASE
|
||||
WHEN daily_usage > 0 THEN FLOOR(current_stock / NULLIF(daily_usage, 0))
|
||||
ELSE 999
|
||||
END as days_of_stock,
|
||||
GREATEST(
|
||||
standard_order_quantity,
|
||||
CEIL(daily_usage * 14)
|
||||
) as recommended_order_quantity
|
||||
FROM usage_analysis
|
||||
WHERE current_stock <= reorder_point
|
||||
ORDER BY days_of_stock ASC, current_stock ASC
|
||||
LIMIT 50
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"tenant_id": tenant_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get reorder recommendations", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_active_tenant_ids(self) -> List[UUID]:
|
||||
"""
|
||||
Get list of active tenant IDs from ingredients table
|
||||
"""
|
||||
try:
|
||||
query = text("SELECT DISTINCT tenant_id FROM ingredients WHERE is_active = true")
|
||||
result = await self.session.execute(query)
|
||||
|
||||
tenant_ids = []
|
||||
for row in result.fetchall():
|
||||
tenant_id = row.tenant_id
|
||||
# Convert to UUID if it's not already
|
||||
if isinstance(tenant_id, UUID):
|
||||
tenant_ids.append(tenant_id)
|
||||
else:
|
||||
tenant_ids.append(UUID(str(tenant_id)))
|
||||
return tenant_ids
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get active tenant IDs", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_stock_after_order(self, ingredient_id: str, order_quantity: float) -> Dict[str, Any]:
|
||||
"""
|
||||
Get stock information after hypothetical order
|
||||
"""
|
||||
try:
|
||||
query = text("""
|
||||
SELECT i.id, i.name,
|
||||
COALESCE(SUM(s.current_quantity), 0) as current_stock,
|
||||
i.low_stock_threshold as minimum_stock,
|
||||
(COALESCE(SUM(s.current_quantity), 0) - :order_quantity) as remaining
|
||||
FROM ingredients i
|
||||
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||
WHERE i.id = :ingredient_id
|
||||
GROUP BY i.id, i.name, i.low_stock_threshold
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {
|
||||
"ingredient_id": ingredient_id,
|
||||
"order_quantity": order_quantity
|
||||
})
|
||||
row = result.fetchone()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock after order", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
@@ -746,3 +746,175 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCoun
|
||||
stock_id=str(stock_id),
|
||||
tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_expiring_products(self, tenant_id: UUID, days_threshold: int = 7) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get products expiring soon or already expired
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
query = text("""
|
||||
SELECT
|
||||
i.id as ingredient_id,
|
||||
i.name as ingredient_name,
|
||||
s.id as stock_id,
|
||||
s.batch_number,
|
||||
s.expiration_date,
|
||||
s.current_quantity,
|
||||
i.unit_of_measure,
|
||||
s.unit_cost,
|
||||
(s.current_quantity * s.unit_cost) as total_value,
|
||||
CASE
|
||||
WHEN s.expiration_date < CURRENT_DATE THEN 'expired'
|
||||
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '1 day' THEN 'expires_today'
|
||||
WHEN s.expiration_date <= CURRENT_DATE + INTERVAL '3 days' THEN 'expires_soon'
|
||||
ELSE 'warning'
|
||||
END as urgency,
|
||||
EXTRACT(DAY FROM (s.expiration_date - CURRENT_DATE)) as days_until_expiry
|
||||
FROM stock s
|
||||
JOIN ingredients i ON s.ingredient_id = i.id
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
AND s.is_available = true
|
||||
AND s.expiration_date <= CURRENT_DATE + (INTERVAL '1 day' * :days_threshold)
|
||||
ORDER BY s.expiration_date ASC, total_value DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {
|
||||
"tenant_id": tenant_id,
|
||||
"days_threshold": days_threshold
|
||||
})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get expiring products", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_temperature_breaches(self, tenant_id: UUID, hours_back: int = 24) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get temperature monitoring breaches
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
query = text("""
|
||||
SELECT
|
||||
tl.id,
|
||||
tl.equipment_id,
|
||||
tl.equipment_name,
|
||||
tl.storage_type,
|
||||
tl.temperature_celsius,
|
||||
tl.min_threshold,
|
||||
tl.max_threshold,
|
||||
tl.is_within_range,
|
||||
tl.recorded_at,
|
||||
tl.alert_triggered,
|
||||
EXTRACT(EPOCH FROM (NOW() - tl.recorded_at))/3600 as hours_ago,
|
||||
CASE
|
||||
WHEN tl.temperature_celsius < tl.min_threshold
|
||||
THEN tl.min_threshold - tl.temperature_celsius
|
||||
WHEN tl.temperature_celsius > tl.max_threshold
|
||||
THEN tl.temperature_celsius - tl.max_threshold
|
||||
ELSE 0
|
||||
END as deviation
|
||||
FROM temperature_logs tl
|
||||
WHERE tl.tenant_id = :tenant_id
|
||||
AND tl.is_within_range = false
|
||||
AND tl.recorded_at > NOW() - (INTERVAL '1 hour' * :hours_back)
|
||||
AND tl.alert_triggered = false
|
||||
ORDER BY deviation DESC, tl.recorded_at DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {
|
||||
"tenant_id": tenant_id,
|
||||
"hours_back": hours_back
|
||||
})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get temperature breaches", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_waste_opportunities(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Identify waste reduction opportunities
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
query = text("""
|
||||
WITH waste_analysis AS (
|
||||
SELECT
|
||||
i.id as ingredient_id,
|
||||
i.name as ingredient_name,
|
||||
i.ingredient_category,
|
||||
COUNT(sm.id) as waste_incidents,
|
||||
SUM(sm.quantity) as total_waste_quantity,
|
||||
SUM(sm.total_cost) as total_waste_cost,
|
||||
AVG(sm.quantity) as avg_waste_per_incident,
|
||||
MAX(sm.movement_date) as last_waste_date
|
||||
FROM stock_movements sm
|
||||
JOIN ingredients i ON sm.ingredient_id = i.id
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
AND sm.movement_type = 'WASTE'
|
||||
AND sm.movement_date > NOW() - INTERVAL '30 days'
|
||||
GROUP BY i.id, i.name, i.ingredient_category
|
||||
HAVING COUNT(sm.id) >= 3 OR SUM(sm.total_cost) > 50
|
||||
)
|
||||
SELECT * FROM waste_analysis
|
||||
ORDER BY total_waste_cost DESC, waste_incidents DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"tenant_id": tenant_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get waste opportunities", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def get_reorder_recommendations(self, tenant_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get ingredients that need reordering based on stock levels and usage
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
query = text("""
|
||||
WITH usage_analysis AS (
|
||||
SELECT
|
||||
i.id,
|
||||
i.name,
|
||||
COALESCE(SUM(s.current_quantity), 0) as current_stock,
|
||||
i.reorder_point,
|
||||
i.low_stock_threshold,
|
||||
COALESCE(SUM(sm.quantity) FILTER (WHERE sm.movement_date > NOW() - INTERVAL '7 days'), 0) / 7 as daily_usage,
|
||||
i.preferred_supplier_id,
|
||||
i.standard_order_quantity
|
||||
FROM ingredients i
|
||||
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
|
||||
AND sm.movement_type = 'PRODUCTION_USE'
|
||||
AND sm.movement_date > NOW() - INTERVAL '7 days'
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
AND i.is_active = true
|
||||
GROUP BY i.id, i.name, i.reorder_point, i.low_stock_threshold,
|
||||
i.preferred_supplier_id, i.standard_order_quantity
|
||||
)
|
||||
SELECT *,
|
||||
CASE
|
||||
WHEN daily_usage > 0 THEN FLOOR(current_stock / NULLIF(daily_usage, 0))
|
||||
ELSE 999
|
||||
END as days_of_stock,
|
||||
GREATEST(
|
||||
standard_order_quantity,
|
||||
CEIL(daily_usage * 14)
|
||||
) as recommended_order_quantity
|
||||
FROM usage_analysis
|
||||
WHERE current_stock <= reorder_point
|
||||
ORDER BY days_of_stock ASC, current_stock ASC
|
||||
LIMIT 50
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"tenant_id": tenant_id})
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get reorder recommendations", error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
@@ -12,7 +12,6 @@ from datetime import datetime
|
||||
import structlog
|
||||
|
||||
from shared.messaging import UnifiedEventPublisher, EVENT_TYPES
|
||||
from app.repositories.inventory_alert_repository import InventoryAlertRepository
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -188,10 +187,9 @@ class InventoryAlertService:
|
||||
|
||||
await self.publisher.publish_alert(
|
||||
tenant_id=tenant_id,
|
||||
event_type="expired_products",
|
||||
event_domain="inventory",
|
||||
event_type="inventory.expired_products",
|
||||
severity="urgent",
|
||||
metadata=metadata
|
||||
data=metadata
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -222,10 +220,9 @@ class InventoryAlertService:
|
||||
|
||||
await self.publisher.publish_alert(
|
||||
tenant_id=tenant_id,
|
||||
event_type="urgent_expiry",
|
||||
event_domain="inventory",
|
||||
event_type="inventory.urgent_expiry",
|
||||
severity="high",
|
||||
metadata=metadata
|
||||
data=metadata
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -256,10 +253,9 @@ class InventoryAlertService:
|
||||
|
||||
await self.publisher.publish_alert(
|
||||
tenant_id=tenant_id,
|
||||
event_type="overstock_warning",
|
||||
event_domain="inventory",
|
||||
event_type="inventory.overstock_warning",
|
||||
severity="medium",
|
||||
metadata=metadata
|
||||
data=metadata
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -287,10 +283,9 @@ class InventoryAlertService:
|
||||
|
||||
await self.publisher.publish_alert(
|
||||
tenant_id=tenant_id,
|
||||
event_type="expired_batches_auto_processed",
|
||||
event_domain="inventory",
|
||||
event_type="inventory.expired_batches_auto_processed",
|
||||
severity="medium",
|
||||
metadata=metadata
|
||||
data=metadata
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
1046
services/inventory/app/services/inventory_scheduler.py
Normal file
1046
services/inventory/app/services/inventory_scheduler.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.repositories.stock_movement_repository import StockMovementRepository
|
||||
from app.repositories.inventory_alert_repository import InventoryAlertRepository
|
||||
from app.repositories.food_safety_repository import FoodSafetyRepository
|
||||
from shared.clients.production_client import create_production_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -320,9 +320,9 @@ class SustainabilityService:
|
||||
'damaged_inventory': inventory_waste * 0.3, # Estimate: 30% damaged
|
||||
}
|
||||
|
||||
# Get waste incidents from inventory alert repository
|
||||
alert_repo = InventoryAlertRepository(db)
|
||||
waste_opportunities = await alert_repo.get_waste_opportunities(tenant_id)
|
||||
# Get waste incidents from food safety repository
|
||||
food_safety_repo = FoodSafetyRepository(db)
|
||||
waste_opportunities = await food_safety_repo.get_waste_opportunities(tenant_id)
|
||||
|
||||
# Sum up all waste incidents for the period
|
||||
total_waste_incidents = sum(item['waste_incidents'] for item in waste_opportunities) if waste_opportunities else 0
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Inventory Seeding Script for Inventory Service
|
||||
Creates realistic Spanish ingredients for demo template tenants
|
||||
|
||||
This script runs as a Kubernetes init job inside the inventory-service container.
|
||||
It populates the template tenants with a comprehensive catalog of ingredients.
|
||||
|
||||
Usage:
|
||||
python /app/scripts/demo/seed_demo_inventory.py
|
||||
|
||||
Environment Variables Required:
|
||||
INVENTORY_DATABASE_URL - PostgreSQL connection string for inventory database
|
||||
DEMO_MODE - Set to 'production' for production seeding
|
||||
LOG_LEVEL - Logging level (default: INFO)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from app.models.inventory import Ingredient
|
||||
|
||||
# Configure logging
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer()
|
||||
]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Fixed Demo Tenant IDs (must match tenant service)
|
||||
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
|
||||
|
||||
|
||||
def load_ingredients_data():
|
||||
"""Load ingredients data from JSON file"""
|
||||
# Look for data file in the same directory as this script
|
||||
data_file = Path(__file__).parent / "ingredientes_es.json"
|
||||
|
||||
if not data_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Ingredients data file not found: {data_file}. "
|
||||
"Make sure ingredientes_es.json is in the same directory as this script."
|
||||
)
|
||||
|
||||
logger.info("Loading ingredients data", file=str(data_file))
|
||||
|
||||
with open(data_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Flatten all ingredient categories into a single list
|
||||
all_ingredients = []
|
||||
for category_name, ingredients in data.items():
|
||||
logger.debug(f"Loading category: {category_name} ({len(ingredients)} items)")
|
||||
all_ingredients.extend(ingredients)
|
||||
|
||||
logger.info(f"Loaded {len(all_ingredients)} ingredients from JSON")
|
||||
return all_ingredients
|
||||
|
||||
|
||||
async def seed_ingredients_for_tenant(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
tenant_name: str,
|
||||
ingredients_data: list
|
||||
) -> dict:
|
||||
"""
|
||||
Seed ingredients for a specific tenant using pre-defined UUIDs
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: UUID of the tenant
|
||||
tenant_name: Name of the tenant (for logging)
|
||||
ingredients_data: List of ingredient dictionaries with pre-defined IDs
|
||||
|
||||
Returns:
|
||||
Dict with seeding statistics
|
||||
"""
|
||||
logger.info("─" * 80)
|
||||
logger.info(f"Seeding ingredients for: {tenant_name}")
|
||||
logger.info(f"Tenant ID: {tenant_id}")
|
||||
logger.info("─" * 80)
|
||||
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for ing_data in ingredients_data:
|
||||
sku = ing_data["sku"]
|
||||
name = ing_data["name"]
|
||||
|
||||
# Check if ingredient already exists for this tenant with this SKU
|
||||
result = await db.execute(
|
||||
select(Ingredient).where(
|
||||
Ingredient.tenant_id == tenant_id,
|
||||
Ingredient.sku == sku
|
||||
)
|
||||
)
|
||||
existing_ingredient = result.scalars().first()
|
||||
|
||||
if existing_ingredient:
|
||||
logger.debug(f" ⏭️ Skipping (exists): {sku} - {name}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Generate tenant-specific UUID by combining base UUID with tenant ID
|
||||
# This ensures each tenant has unique IDs but they're deterministic (same on re-run)
|
||||
base_id = uuid.UUID(ing_data["id"])
|
||||
# XOR the base ID with the tenant ID to create a tenant-specific ID
|
||||
tenant_int = int(tenant_id.hex, 16)
|
||||
base_int = int(base_id.hex, 16)
|
||||
ingredient_id = uuid.UUID(int=tenant_int ^ base_int)
|
||||
|
||||
# Create new ingredient
|
||||
ingredient = Ingredient(
|
||||
id=ingredient_id,
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
sku=sku,
|
||||
barcode=None, # Could generate EAN-13 barcodes if needed
|
||||
product_type=ing_data["product_type"],
|
||||
ingredient_category=ing_data["ingredient_category"],
|
||||
product_category=ing_data["product_category"],
|
||||
subcategory=ing_data.get("subcategory"),
|
||||
description=ing_data["description"],
|
||||
brand=ing_data.get("brand"),
|
||||
unit_of_measure=ing_data["unit_of_measure"],
|
||||
package_size=None,
|
||||
average_cost=ing_data["average_cost"],
|
||||
last_purchase_price=ing_data["average_cost"],
|
||||
standard_cost=ing_data["average_cost"],
|
||||
low_stock_threshold=ing_data.get("low_stock_threshold", 10.0),
|
||||
reorder_point=ing_data.get("reorder_point", 20.0),
|
||||
reorder_quantity=ing_data.get("reorder_point", 20.0) * 2,
|
||||
max_stock_level=ing_data.get("reorder_point", 20.0) * 5,
|
||||
shelf_life_days=ing_data.get("shelf_life_days"),
|
||||
is_perishable=ing_data.get("is_perishable", False),
|
||||
is_active=True,
|
||||
allergen_info=ing_data.get("allergen_info") if ing_data.get("allergen_info") else None,
|
||||
# NEW: Local production support (Sprint 5)
|
||||
produced_locally=ing_data.get("produced_locally", False),
|
||||
recipe_id=uuid.UUID(ing_data["recipe_id"]) if ing_data.get("recipe_id") else None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
db.add(ingredient)
|
||||
created_count += 1
|
||||
|
||||
logger.debug(f" ✅ Created: {sku} - {name}")
|
||||
|
||||
# Commit all changes for this tenant
|
||||
await db.commit()
|
||||
|
||||
logger.info(f" 📊 Created: {created_count}, Skipped: {skipped_count}")
|
||||
logger.info("")
|
||||
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"tenant_name": tenant_name,
|
||||
"created": created_count,
|
||||
"skipped": skipped_count,
|
||||
"total": len(ingredients_data)
|
||||
}
|
||||
|
||||
|
||||
async def seed_inventory(db: AsyncSession):
|
||||
"""
|
||||
Seed inventory for all demo template tenants
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with overall seeding statistics
|
||||
"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("📦 Starting Demo Inventory Seeding")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Load ingredients data once
|
||||
try:
|
||||
ingredients_data = load_ingredients_data()
|
||||
except FileNotFoundError as e:
|
||||
logger.error(str(e))
|
||||
raise
|
||||
|
||||
results = []
|
||||
|
||||
# Seed for Professional Bakery (single location)
|
||||
logger.info("")
|
||||
result_professional = await seed_ingredients_for_tenant(
|
||||
db,
|
||||
DEMO_TENANT_PROFESSIONAL,
|
||||
"Panadería Artesana Madrid (Professional)",
|
||||
ingredients_data
|
||||
)
|
||||
results.append(result_professional)
|
||||
|
||||
# Seed for Enterprise Parent (central production - Obrador)
|
||||
logger.info("")
|
||||
result_enterprise_parent = await seed_ingredients_for_tenant(
|
||||
db,
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN,
|
||||
"Panadería Central - Obrador Madrid (Enterprise Parent)",
|
||||
ingredients_data
|
||||
)
|
||||
results.append(result_enterprise_parent)
|
||||
|
||||
# Calculate totals
|
||||
total_created = sum(r["created"] for r in results)
|
||||
total_skipped = sum(r["skipped"] for r in results)
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("✅ Demo Inventory Seeding Completed")
|
||||
logger.info("=" * 80)
|
||||
|
||||
return {
|
||||
"service": "inventory",
|
||||
"tenants_seeded": len(results),
|
||||
"total_created": total_created,
|
||||
"total_skipped": total_skipped,
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main execution function"""
|
||||
|
||||
logger.info("Demo Inventory Seeding Script Starting")
|
||||
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
|
||||
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("INVENTORY_DATABASE_URL") or os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("❌ INVENTORY_DATABASE_URL or DATABASE_URL environment variable must be set")
|
||||
return 1
|
||||
|
||||
# Convert to async URL if needed
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
logger.info("Connecting to inventory database")
|
||||
|
||||
# Create engine and session
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10
|
||||
)
|
||||
|
||||
async_session = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await seed_inventory(session)
|
||||
|
||||
logger.info("")
|
||||
logger.info("📊 Seeding Summary:")
|
||||
logger.info(f" ✅ Tenants seeded: {result['tenants_seeded']}")
|
||||
logger.info(f" ✅ Total created: {result['total_created']}")
|
||||
logger.info(f" ⏭️ Total skipped: {result['total_skipped']}")
|
||||
logger.info("")
|
||||
|
||||
# Print per-tenant details
|
||||
for tenant_result in result['results']:
|
||||
logger.info(
|
||||
f" {tenant_result['tenant_name']}: "
|
||||
f"{tenant_result['created']} created, {tenant_result['skipped']} skipped"
|
||||
)
|
||||
|
||||
logger.info("")
|
||||
logger.info("🎉 Success! Ingredient catalog is ready for cloning.")
|
||||
logger.info("")
|
||||
logger.info("Ingredients by category:")
|
||||
logger.info(" • Harinas: 6 tipos (T55, T65, Fuerza, Integral, Centeno, Espelta)")
|
||||
logger.info(" • Lácteos: 4 tipos (Mantequilla, Leche, Nata, Huevos)")
|
||||
logger.info(" • Levaduras: 3 tipos (Fresca, Seca, Masa Madre)")
|
||||
logger.info(" • Básicos: 3 tipos (Sal, Azúcar, Agua)")
|
||||
logger.info(" • Especiales: 5 tipos (Chocolate, Almendras, etc.)")
|
||||
logger.info(" • Productos: 3 referencias")
|
||||
logger.info("")
|
||||
logger.info("Next steps:")
|
||||
logger.info(" 1. Run seed jobs for other services (recipes, suppliers, etc.)")
|
||||
logger.info(" 2. Verify ingredient data in database")
|
||||
logger.info(" 3. Test demo session creation with inventory cloning")
|
||||
logger.info("")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("❌ Demo Inventory Seeding Failed")
|
||||
logger.error("=" * 80)
|
||||
logger.error("Error: %s", str(e))
|
||||
logger.error("", exc_info=True)
|
||||
return 1
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -1,347 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Inventory Retail Seeding Script for Inventory Service
|
||||
Creates finished product inventory for enterprise child tenants (retail outlets)
|
||||
|
||||
This script runs as a Kubernetes init job inside the inventory-service container.
|
||||
It populates the child retail tenants with FINISHED PRODUCTS ONLY (no raw ingredients).
|
||||
|
||||
Usage:
|
||||
python /app/scripts/demo/seed_demo_inventory_retail.py
|
||||
|
||||
Environment Variables Required:
|
||||
INVENTORY_DATABASE_URL - PostgreSQL connection string for inventory database
|
||||
DEMO_MODE - Set to 'production' for production seeding
|
||||
LOG_LEVEL - Logging level (default: INFO)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
# Add shared to path for demo utilities
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
|
||||
from app.models.inventory import Ingredient, ProductType
|
||||
|
||||
# Configure logging
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer()
|
||||
]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Fixed Demo Tenant IDs (must match tenant service)
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
|
||||
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") # Madrid Centro
|
||||
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") # Barcelona Gràcia
|
||||
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") # Valencia Ruzafa
|
||||
|
||||
# Child tenant configurations
|
||||
CHILD_TENANTS = [
|
||||
(DEMO_TENANT_CHILD_1, "Madrid Centro"),
|
||||
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia"),
|
||||
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa")
|
||||
]
|
||||
|
||||
|
||||
def load_finished_products_data():
|
||||
"""Load ONLY finished products from JSON file (no raw ingredients)"""
|
||||
# Look for data file in the same directory as this script
|
||||
data_file = Path(__file__).parent / "ingredientes_es.json"
|
||||
|
||||
if not data_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Ingredients data file not found: {data_file}. "
|
||||
"Make sure ingredientes_es.json is in the same directory as this script."
|
||||
)
|
||||
|
||||
logger.info("Loading finished products data", file=str(data_file))
|
||||
|
||||
with open(data_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Extract ONLY finished products (not raw ingredients)
|
||||
finished_products = data.get("productos_terminados", [])
|
||||
|
||||
logger.info(f"Loaded {len(finished_products)} finished products from JSON")
|
||||
logger.info("NOTE: Raw ingredients (flour, yeast, etc.) are NOT seeded for retail outlets")
|
||||
|
||||
return finished_products
|
||||
|
||||
|
||||
async def seed_retail_inventory_for_tenant(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
parent_tenant_id: uuid.UUID,
|
||||
tenant_name: str,
|
||||
products_data: list
|
||||
) -> dict:
|
||||
"""
|
||||
Seed finished product inventory for a child retail tenant using XOR ID transformation
|
||||
|
||||
This ensures retail outlets have the same product catalog as their parent (central production),
|
||||
using deterministic UUIDs that map correctly across tenants.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: UUID of the child tenant
|
||||
parent_tenant_id: UUID of the parent tenant (for XOR transformation)
|
||||
tenant_name: Name of the tenant (for logging)
|
||||
products_data: List of finished product dictionaries with pre-defined IDs
|
||||
|
||||
Returns:
|
||||
Dict with seeding statistics
|
||||
"""
|
||||
logger.info("─" * 80)
|
||||
logger.info(f"Seeding retail inventory for: {tenant_name}")
|
||||
logger.info(f"Child Tenant ID: {tenant_id}")
|
||||
logger.info(f"Parent Tenant ID: {parent_tenant_id}")
|
||||
logger.info("─" * 80)
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for product_data in products_data:
|
||||
sku = product_data["sku"]
|
||||
name = product_data["name"]
|
||||
|
||||
# Check if product already exists for this tenant with this SKU
|
||||
result = await db.execute(
|
||||
select(Ingredient).where(
|
||||
Ingredient.tenant_id == tenant_id,
|
||||
Ingredient.sku == sku
|
||||
)
|
||||
)
|
||||
existing_product = result.scalars().first()
|
||||
|
||||
if existing_product:
|
||||
logger.debug(f" ⏭️ Skipping (exists): {sku} - {name}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Generate tenant-specific UUID using XOR transformation
|
||||
# This ensures the child's product IDs map to the parent's product IDs
|
||||
base_id = uuid.UUID(product_data["id"])
|
||||
tenant_int = int(tenant_id.hex, 16)
|
||||
base_int = int(base_id.hex, 16)
|
||||
product_id = uuid.UUID(int=tenant_int ^ base_int)
|
||||
|
||||
# Create new finished product for retail outlet
|
||||
product = Ingredient(
|
||||
id=product_id,
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
sku=sku,
|
||||
barcode=None, # Could be set by retail outlet
|
||||
product_type=ProductType.FINISHED_PRODUCT, # CRITICAL: Only finished products
|
||||
ingredient_category=None, # Not applicable for finished products
|
||||
product_category=product_data["product_category"], # BREAD, CROISSANTS, PASTRIES, etc.
|
||||
subcategory=product_data.get("subcategory"),
|
||||
description=product_data["description"],
|
||||
brand=f"Obrador Madrid", # Branded from central production
|
||||
unit_of_measure=product_data["unit_of_measure"],
|
||||
package_size=None,
|
||||
average_cost=product_data["average_cost"], # Transfer price from central production
|
||||
last_purchase_price=product_data["average_cost"],
|
||||
standard_cost=product_data["average_cost"],
|
||||
# Retail outlets typically don't manage reorder points - they order from parent
|
||||
low_stock_threshold=None,
|
||||
reorder_point=None,
|
||||
reorder_quantity=None,
|
||||
max_stock_level=None,
|
||||
shelf_life_days=product_data.get("shelf_life_days"),
|
||||
is_perishable=product_data.get("is_perishable", True), # Bakery products are perishable
|
||||
is_active=True,
|
||||
allergen_info=product_data.get("allergen_info") if product_data.get("allergen_info") else None,
|
||||
# Retail outlets receive products, don't produce them locally
|
||||
produced_locally=False,
|
||||
recipe_id=None, # Recipes belong to central production, not retail
|
||||
created_at=BASE_REFERENCE_DATE,
|
||||
updated_at=BASE_REFERENCE_DATE
|
||||
)
|
||||
|
||||
db.add(product)
|
||||
created_count += 1
|
||||
|
||||
logger.debug(f" ✅ Created: {sku} - {name}")
|
||||
|
||||
# Commit all changes for this tenant
|
||||
await db.commit()
|
||||
|
||||
logger.info(f" 📊 Created: {created_count}, Skipped: {skipped_count}")
|
||||
logger.info("")
|
||||
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"tenant_name": tenant_name,
|
||||
"created": created_count,
|
||||
"skipped": skipped_count,
|
||||
"total": len(products_data)
|
||||
}
|
||||
|
||||
|
||||
async def seed_retail_inventory(db: AsyncSession):
|
||||
"""
|
||||
Seed retail inventory for all child tenant templates
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with overall seeding statistics
|
||||
"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("🏪 Starting Demo Retail Inventory Seeding")
|
||||
logger.info("=" * 80)
|
||||
logger.info("NOTE: Seeding FINISHED PRODUCTS ONLY for child retail outlets")
|
||||
logger.info("Raw ingredients (flour, yeast, etc.) are NOT seeded for retail tenants")
|
||||
logger.info("")
|
||||
|
||||
# Load finished products data once
|
||||
try:
|
||||
products_data = load_finished_products_data()
|
||||
except FileNotFoundError as e:
|
||||
logger.error(str(e))
|
||||
raise
|
||||
|
||||
results = []
|
||||
|
||||
# Seed for each child retail outlet
|
||||
for child_tenant_id, child_tenant_name in CHILD_TENANTS:
|
||||
logger.info("")
|
||||
result = await seed_retail_inventory_for_tenant(
|
||||
db,
|
||||
child_tenant_id,
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN,
|
||||
f"{child_tenant_name} (Retail Outlet)",
|
||||
products_data
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Calculate totals
|
||||
total_created = sum(r["created"] for r in results)
|
||||
total_skipped = sum(r["skipped"] for r in results)
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("✅ Demo Retail Inventory Seeding Completed")
|
||||
logger.info("=" * 80)
|
||||
|
||||
return {
|
||||
"service": "inventory_retail",
|
||||
"tenants_seeded": len(results),
|
||||
"total_created": total_created,
|
||||
"total_skipped": total_skipped,
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main execution function"""
|
||||
|
||||
logger.info("Demo Retail Inventory Seeding Script Starting")
|
||||
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
|
||||
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("INVENTORY_DATABASE_URL") or os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("❌ INVENTORY_DATABASE_URL or DATABASE_URL environment variable must be set")
|
||||
return 1
|
||||
|
||||
# Convert to async URL if needed
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
logger.info("Connecting to inventory database")
|
||||
|
||||
# Create engine and session
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10
|
||||
)
|
||||
|
||||
async_session = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await seed_retail_inventory(session)
|
||||
|
||||
logger.info("")
|
||||
logger.info("📊 Retail Inventory Seeding Summary:")
|
||||
logger.info(f" ✅ Retail outlets seeded: {result['tenants_seeded']}")
|
||||
logger.info(f" ✅ Total products created: {result['total_created']}")
|
||||
logger.info(f" ⏭️ Total skipped: {result['total_skipped']}")
|
||||
logger.info("")
|
||||
|
||||
# Print per-tenant details
|
||||
for tenant_result in result['results']:
|
||||
logger.info(
|
||||
f" {tenant_result['tenant_name']}: "
|
||||
f"{tenant_result['created']} products created, {tenant_result['skipped']} skipped"
|
||||
)
|
||||
|
||||
logger.info("")
|
||||
logger.info("🎉 Success! Retail inventory catalog is ready for cloning.")
|
||||
logger.info("")
|
||||
logger.info("Finished products seeded:")
|
||||
logger.info(" • Baguette Tradicional")
|
||||
logger.info(" • Croissant de Mantequilla")
|
||||
logger.info(" • Pan de Pueblo")
|
||||
logger.info(" • Napolitana de Chocolate")
|
||||
logger.info("")
|
||||
logger.info("Key points:")
|
||||
logger.info(" ✓ Only finished products seeded (no raw ingredients)")
|
||||
logger.info(" ✓ Product IDs use XOR transformation to match parent catalog")
|
||||
logger.info(" ✓ All products marked as produced_locally=False (received from parent)")
|
||||
logger.info(" ✓ Retail outlets will receive stock from central production via distribution")
|
||||
logger.info("")
|
||||
logger.info("Next steps:")
|
||||
logger.info(" 1. Seed retail stock levels (initial inventory)")
|
||||
logger.info(" 2. Seed retail sales history")
|
||||
logger.info(" 3. Seed customer data and orders")
|
||||
logger.info(" 4. Test enterprise demo session creation")
|
||||
logger.info("")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("❌ Demo Retail Inventory Seeding Failed")
|
||||
logger.error("=" * 80)
|
||||
logger.error("Error: %s", str(e))
|
||||
logger.error("", exc_info=True)
|
||||
return 1
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,394 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Demo Retail Stock Seeding Script for Inventory Service
|
||||
Creates realistic stock levels for finished products at child retail outlets
|
||||
|
||||
This script runs as a Kubernetes init job inside the inventory-service container.
|
||||
It populates child retail tenants with stock levels for FINISHED PRODUCTS ONLY.
|
||||
|
||||
Usage:
|
||||
python /app/scripts/demo/seed_demo_stock_retail.py
|
||||
|
||||
Environment Variables Required:
|
||||
INVENTORY_DATABASE_URL - PostgreSQL connection string for inventory database
|
||||
DEMO_MODE - Set to 'production' for production seeding
|
||||
LOG_LEVEL - Logging level (default: INFO)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
import random
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from decimal import Decimal
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
# Add shared to path for demo utilities
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select
|
||||
import structlog
|
||||
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
|
||||
from app.models.inventory import Ingredient, Stock, ProductType
|
||||
|
||||
# Configure logging
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer()
|
||||
]
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Fixed Demo Tenant IDs (must match tenant service)
|
||||
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
|
||||
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") # Madrid Centro
|
||||
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") # Barcelona Gràcia
|
||||
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") # Valencia Ruzafa
|
||||
|
||||
# Child tenant configurations
|
||||
CHILD_TENANTS = [
|
||||
(DEMO_TENANT_CHILD_1, "Madrid Centro", 1.2), # Larger store, 20% more stock
|
||||
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia", 1.0), # Medium store, baseline stock
|
||||
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa", 0.8) # Smaller store, 20% less stock
|
||||
]
|
||||
|
||||
# Retail stock configuration for finished products
|
||||
# Daily sales estimates (units per day) for each product type
|
||||
DAILY_SALES_BY_SKU = {
|
||||
"PRO-BAG-001": 80, # Baguette Tradicional - high volume
|
||||
"PRO-CRO-001": 50, # Croissant de Mantequilla - popular breakfast item
|
||||
"PRO-PUE-001": 30, # Pan de Pueblo - specialty item
|
||||
"PRO-NAP-001": 40 # Napolitana de Chocolate - pastry item
|
||||
}
|
||||
|
||||
# Storage locations for retail outlets
|
||||
RETAIL_STORAGE_LOCATIONS = ["Display Case", "Back Room", "Cooling Shelf", "Storage Area"]
|
||||
|
||||
|
||||
def generate_retail_batch_number(tenant_id: uuid.UUID, product_sku: str, days_ago: int) -> str:
|
||||
"""Generate a realistic batch number for retail stock"""
|
||||
tenant_short = str(tenant_id).split('-')[0].upper()[:4]
|
||||
date_code = (BASE_REFERENCE_DATE - timedelta(days=days_ago)).strftime("%Y%m%d")
|
||||
return f"RET-{tenant_short}-{product_sku}-{date_code}"
|
||||
|
||||
|
||||
def calculate_retail_stock_quantity(
|
||||
product_sku: str,
|
||||
size_multiplier: float,
|
||||
create_some_low_stock: bool = False
|
||||
) -> float:
|
||||
"""
|
||||
Calculate realistic retail stock quantity based on daily sales
|
||||
|
||||
Args:
|
||||
product_sku: SKU of the finished product
|
||||
size_multiplier: Store size multiplier (0.8 for small, 1.0 for medium, 1.2 for large)
|
||||
create_some_low_stock: If True, 20% chance of low stock scenario
|
||||
|
||||
Returns:
|
||||
Stock quantity in units
|
||||
"""
|
||||
daily_sales = DAILY_SALES_BY_SKU.get(product_sku, 20)
|
||||
|
||||
# Retail outlets typically stock 1-3 days worth (fresh bakery products)
|
||||
if create_some_low_stock and random.random() < 0.2:
|
||||
# Low stock: 0.3-0.8 days worth (need restock soon)
|
||||
days_of_supply = random.uniform(0.3, 0.8)
|
||||
else:
|
||||
# Normal: 1-2.5 days worth
|
||||
days_of_supply = random.uniform(1.0, 2.5)
|
||||
|
||||
quantity = daily_sales * days_of_supply * size_multiplier
|
||||
|
||||
# Add realistic variability
|
||||
quantity *= random.uniform(0.85, 1.15)
|
||||
|
||||
return max(5.0, round(quantity)) # Minimum 5 units
|
||||
|
||||
|
||||
async def seed_retail_stock_for_tenant(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
tenant_name: str,
|
||||
size_multiplier: float
|
||||
) -> dict:
|
||||
"""
|
||||
Seed realistic stock levels for a child retail tenant
|
||||
|
||||
Creates multiple stock batches per product with varied freshness levels,
|
||||
simulating realistic retail bakery inventory with:
|
||||
- Fresh stock from today's/yesterday's delivery
|
||||
- Some expiring soon items
|
||||
- Varied batch sizes and locations
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tenant_id: UUID of the child tenant
|
||||
tenant_name: Name of the tenant (for logging)
|
||||
size_multiplier: Store size multiplier for stock quantities
|
||||
|
||||
Returns:
|
||||
Dict with seeding statistics
|
||||
"""
|
||||
logger.info("─" * 80)
|
||||
logger.info(f"Seeding retail stock for: {tenant_name}")
|
||||
logger.info(f"Tenant ID: {tenant_id}")
|
||||
logger.info(f"Size Multiplier: {size_multiplier}x")
|
||||
logger.info("─" * 80)
|
||||
|
||||
# Get all finished products for this tenant
|
||||
result = await db.execute(
|
||||
select(Ingredient).where(
|
||||
Ingredient.tenant_id == tenant_id,
|
||||
Ingredient.product_type == ProductType.FINISHED_PRODUCT,
|
||||
Ingredient.is_active == True
|
||||
)
|
||||
)
|
||||
products = result.scalars().all()
|
||||
|
||||
if not products:
|
||||
logger.warning(f"No finished products found for tenant {tenant_id}")
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"tenant_name": tenant_name,
|
||||
"stock_batches_created": 0,
|
||||
"products_stocked": 0
|
||||
}
|
||||
|
||||
created_batches = 0
|
||||
|
||||
for product in products:
|
||||
# Create 2-4 batches per product (simulating multiple deliveries/batches)
|
||||
num_batches = random.randint(2, 4)
|
||||
|
||||
for batch_index in range(num_batches):
|
||||
# Vary delivery dates (0-2 days ago for fresh bakery products)
|
||||
days_ago = random.randint(0, 2)
|
||||
received_date = BASE_REFERENCE_DATE - timedelta(days=days_ago)
|
||||
|
||||
# Calculate expiration based on shelf life
|
||||
shelf_life_days = product.shelf_life_days or 2 # Default 2 days for bakery
|
||||
expiration_date = received_date + timedelta(days=shelf_life_days)
|
||||
|
||||
# Calculate quantity for this batch
|
||||
# Split total quantity across batches with variation
|
||||
batch_quantity_factor = random.uniform(0.3, 0.7) # Each batch is 30-70% of average
|
||||
quantity = calculate_retail_stock_quantity(
|
||||
product.sku,
|
||||
size_multiplier,
|
||||
create_some_low_stock=(batch_index == 0) # First batch might be low
|
||||
) * batch_quantity_factor
|
||||
|
||||
# Determine if product is still good
|
||||
days_until_expiration = (expiration_date - BASE_REFERENCE_DATE).days
|
||||
is_expired = days_until_expiration < 0
|
||||
is_available = not is_expired
|
||||
quality_status = "expired" if is_expired else "good"
|
||||
|
||||
# Random storage location
|
||||
storage_location = random.choice(RETAIL_STORAGE_LOCATIONS)
|
||||
|
||||
# Create stock batch
|
||||
stock_batch = Stock(
|
||||
id=uuid.uuid4(),
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=product.id,
|
||||
supplier_id=DEMO_TENANT_ENTERPRISE_CHAIN, # Supplied by parent (Obrador)
|
||||
batch_number=generate_retail_batch_number(tenant_id, product.sku, days_ago),
|
||||
lot_number=f"LOT-{BASE_REFERENCE_DATE.strftime('%Y%m%d')}-{batch_index+1:02d}",
|
||||
supplier_batch_ref=f"OBRADOR-{received_date.strftime('%Y%m%d')}-{random.randint(1000, 9999)}",
|
||||
production_stage="fully_baked", # Retail receives fully baked products
|
||||
transformation_reference=None,
|
||||
current_quantity=quantity,
|
||||
reserved_quantity=0.0,
|
||||
available_quantity=quantity if is_available else 0.0,
|
||||
received_date=received_date,
|
||||
expiration_date=expiration_date,
|
||||
best_before_date=expiration_date - timedelta(hours=12) if shelf_life_days == 1 else None,
|
||||
original_expiration_date=None,
|
||||
transformation_date=None,
|
||||
final_expiration_date=expiration_date,
|
||||
unit_cost=Decimal(str(product.average_cost or 0.5)),
|
||||
total_cost=Decimal(str(product.average_cost or 0.5)) * Decimal(str(quantity)),
|
||||
storage_location=storage_location,
|
||||
warehouse_zone=None, # Retail outlets don't have warehouse zones
|
||||
shelf_position=None,
|
||||
requires_refrigeration=False, # Most bakery products don't require refrigeration
|
||||
requires_freezing=False,
|
||||
storage_temperature_min=None,
|
||||
storage_temperature_max=25.0 if product.is_perishable else None, # Room temp
|
||||
storage_humidity_max=65.0 if product.is_perishable else None,
|
||||
shelf_life_days=shelf_life_days,
|
||||
storage_instructions=product.storage_instructions if hasattr(product, 'storage_instructions') else None,
|
||||
is_available=is_available,
|
||||
is_expired=is_expired,
|
||||
quality_status=quality_status,
|
||||
created_at=received_date,
|
||||
updated_at=BASE_REFERENCE_DATE
|
||||
)
|
||||
|
||||
db.add(stock_batch)
|
||||
created_batches += 1
|
||||
|
||||
logger.debug(
|
||||
f" ✅ Created stock batch: {product.name} - "
|
||||
f"{quantity:.0f} units, expires in {days_until_expiration} days"
|
||||
)
|
||||
|
||||
# Commit all changes for this tenant
|
||||
await db.commit()
|
||||
|
||||
logger.info(f" 📊 Stock batches created: {created_batches} across {len(products)} products")
|
||||
logger.info("")
|
||||
|
||||
return {
|
||||
"tenant_id": str(tenant_id),
|
||||
"tenant_name": tenant_name,
|
||||
"stock_batches_created": created_batches,
|
||||
"products_stocked": len(products)
|
||||
}
|
||||
|
||||
|
||||
async def seed_retail_stock(db: AsyncSession):
|
||||
"""
|
||||
Seed retail stock for all child tenant templates
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with overall seeding statistics
|
||||
"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("📦 Starting Demo Retail Stock Seeding")
|
||||
logger.info("=" * 80)
|
||||
logger.info("Creating stock levels for finished products at retail outlets")
|
||||
logger.info("")
|
||||
|
||||
results = []
|
||||
|
||||
# Seed for each child retail outlet
|
||||
for child_tenant_id, child_tenant_name, size_multiplier in CHILD_TENANTS:
|
||||
logger.info("")
|
||||
result = await seed_retail_stock_for_tenant(
|
||||
db,
|
||||
child_tenant_id,
|
||||
f"{child_tenant_name} (Retail Outlet)",
|
||||
size_multiplier
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Calculate totals
|
||||
total_batches = sum(r["stock_batches_created"] for r in results)
|
||||
total_products = sum(r["products_stocked"] for r in results)
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("✅ Demo Retail Stock Seeding Completed")
|
||||
logger.info("=" * 80)
|
||||
|
||||
return {
|
||||
"service": "inventory_stock_retail",
|
||||
"tenants_seeded": len(results),
|
||||
"total_batches_created": total_batches,
|
||||
"total_products_stocked": total_products,
|
||||
"results": results
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main execution function"""
|
||||
|
||||
logger.info("Demo Retail Stock Seeding Script Starting")
|
||||
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
|
||||
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("INVENTORY_DATABASE_URL") or os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("❌ INVENTORY_DATABASE_URL or DATABASE_URL environment variable must be set")
|
||||
return 1
|
||||
|
||||
# Convert to async URL if needed
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
logger.info("Connecting to inventory database")
|
||||
|
||||
# Create engine and session
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10
|
||||
)
|
||||
|
||||
async_session = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await seed_retail_stock(session)
|
||||
|
||||
logger.info("")
|
||||
logger.info("📊 Retail Stock Seeding Summary:")
|
||||
logger.info(f" ✅ Retail outlets seeded: {result['tenants_seeded']}")
|
||||
logger.info(f" ✅ Total stock batches: {result['total_batches_created']}")
|
||||
logger.info(f" ✅ Products stocked: {result['total_products_stocked']}")
|
||||
logger.info("")
|
||||
|
||||
# Print per-tenant details
|
||||
for tenant_result in result['results']:
|
||||
logger.info(
|
||||
f" {tenant_result['tenant_name']}: "
|
||||
f"{tenant_result['stock_batches_created']} batches, "
|
||||
f"{tenant_result['products_stocked']} products"
|
||||
)
|
||||
|
||||
logger.info("")
|
||||
logger.info("🎉 Success! Retail stock levels are ready for cloning.")
|
||||
logger.info("")
|
||||
logger.info("Stock characteristics:")
|
||||
logger.info(" ✓ Multiple batches per product (2-4 batches)")
|
||||
logger.info(" ✓ Varied freshness levels (0-2 days old)")
|
||||
logger.info(" ✓ Realistic quantities based on store size")
|
||||
logger.info(" ✓ Some low-stock scenarios for demo alerts")
|
||||
logger.info(" ✓ Expiration tracking enabled")
|
||||
logger.info("")
|
||||
logger.info("Next steps:")
|
||||
logger.info(" 1. Seed retail sales history")
|
||||
logger.info(" 2. Seed customer data")
|
||||
logger.info(" 3. Test stock alerts and reorder triggers")
|
||||
logger.info("")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("❌ Demo Retail Stock Seeding Failed")
|
||||
logger.error("=" * 80)
|
||||
logger.error("Error: %s", str(e))
|
||||
logger.error("", exc_info=True)
|
||||
return 1
|
||||
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user