diff --git a/FRONTEND_API_ALIGNMENT_REPORT.md b/FRONTEND_API_ALIGNMENT_REPORT.md deleted file mode 100644 index b0a8d5b9..00000000 --- a/FRONTEND_API_ALIGNMENT_REPORT.md +++ /dev/null @@ -1,252 +0,0 @@ -# Frontend API Alignment Analysis Report - -## Executive Summary - -The frontend API abstraction layer has been thoroughly analyzed and tested against the backend services. The results show a **62.5% success rate** with **5 out of 8 tests passing**. The frontend API structure is well-designed and mostly aligned with backend expectations, but there are some specific areas that need attention. - -## ✅ What Works Well - -### 1. Authentication Service (`AuthService`) -- **Perfect Alignment**: Registration and login endpoints work flawlessly -- **Response Structure**: Backend response matches frontend expectations exactly -- **Token Handling**: Access token, refresh token, and user object are properly structured -- **Type Safety**: Frontend types match backend schemas - -```typescript -// Frontend expectation matches backend reality -interface LoginResponse { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - user?: UserData; -} -``` - -### 2. Tenant Service (`TenantService`) -- **Excellent Alignment**: Tenant creation works perfectly through `/tenants/register` -- **Response Structure**: All expected fields present (`id`, `name`, `owner_id`, `is_active`, `created_at`) -- **Additional Fields**: Backend provides extra useful fields (`subdomain`, `business_type`, `subscription_tier`) - -### 3. Data Service - Validation (`DataService.validateSalesData`) -- **Perfect Validation**: Data validation endpoint works correctly -- **Rich Response**: Provides comprehensive validation information including file size, processing estimates, and suggestions -- **Error Handling**: Proper validation result structure with errors, warnings, and summary - -## ⚠️ Issues Found & Recommendations - -### 1. Data Service - Import Endpoint Mismatch - -**Issue**: The frontend `uploadSalesHistory()` method is calling the validation endpoint instead of the actual import endpoint. - -**Current Frontend Code**: -```typescript -async uploadSalesHistory(tenantId: string, data, additionalData = {}) { - // This calls validation endpoint, not import - return this.apiClient.post(`/tenants/${tenantId}/sales/import/validate-json`, requestData); -} -``` - -**Backend Reality**: -- Validation: `/tenants/{tenant_id}/sales/import/validate-json` ✅ -- Actual Import: `/tenants/{tenant_id}/sales/import` ❌ (not being called) - -**Recommendation**: Fix the frontend service to call the correct import endpoint: -```typescript -async uploadSalesHistory(tenantId: string, file: File, additionalData = {}) { - return this.apiClient.upload(`/tenants/${tenantId}/sales/import`, file, additionalData); -} -``` - -### 2. Training Service - Status Endpoint Issue - -**Issue**: Training job status endpoint returns 404 "Training job not found" - -**Analysis**: -- Job creation works: ✅ `/tenants/{tenant_id}/training/jobs` -- Job status fails: ❌ `/tenants/{tenant_id}/training/jobs/{job_id}/status` - -**Likely Cause**: There might be a timing issue where the job isn't immediately available for status queries, or the endpoint path differs from frontend expectations. - -**Recommendation**: -1. Add retry logic with exponential backoff for status checks -2. Verify the exact backend endpoint path in the training service -3. Consider using WebSocket for real-time status updates instead - -### 3. Data Service - Products List Empty - -**Issue**: Products list returns empty array even after data upload - -**Analysis**: -- Data validation shows 3,655 records ✅ -- Products endpoint returns `[]` ❌ - -**Likely Cause**: The data wasn't actually imported (see Issue #1), so no products are available in the database. - -**Recommendation**: Fix the import endpoint first, then products should be available. - -### 4. Forecasting Service - Missing Required Fields - -**Issue**: Forecast creation fails due to missing required `location` field - -**Frontend Request**: -```javascript -{ - "product_name": "pan", - "forecast_date": "2025-08-08", - "forecast_days": 7, - "confidence_level": 0.85 -} -``` - -**Backend Expectation**: -```python -# Missing required field: location -class ForecastRequest(BaseModel): - product_name: str - location: LocationData # Required but missing - # ... other fields -``` - -**Recommendation**: Update frontend forecasting service to include location data: -```typescript -async createForecast(tenantId: string, request: ForecastRequest) { - const forecastData = { - ...request, - location: { - latitude: 40.4168, // Get from tenant data - longitude: -3.7038 - } - }; - return this.apiClient.post(`/tenants/${tenantId}/forecasts/single`, forecastData); -} -``` - -## 📋 Frontend API Improvements Needed - -### 1. **Data Service Import Method** -```typescript -// Fix the uploadSalesHistory method -async uploadSalesHistory(tenantId: string, file: File, additionalData = {}) { - return this.apiClient.upload(`/tenants/${tenantId}/sales/import`, file, { - file_format: this.detectFileFormat(file), - source: 'onboarding_upload', - ...additionalData - }); -} -``` - -### 2. **Training Service Status Polling** -```typescript -async waitForTrainingCompletion(tenantId: string, jobId: string, maxAttempts = 30) { - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - const status = await this.getTrainingJobStatus(tenantId, jobId); - if (status.status === 'completed' || status.status === 'failed') { - return status; - } - await this.sleep(5000); // Wait 5 seconds - } catch (error) { - if (attempt < 3) continue; // Retry first few attempts - throw error; - } - } - throw new Error('Training status timeout'); -} -``` - -### 3. **Forecasting Service Location Support** -```typescript -async createForecast(tenantId: string, request: ForecastRequest) { - // Get tenant location or use default - const tenant = await this.tenantService.getTenant(tenantId); - const location = tenant.location || { latitude: 40.4168, longitude: -3.7038 }; - - return this.apiClient.post(`/tenants/${tenantId}/forecasts/single`, { - ...request, - location - }); -} -``` - -### 4. **Enhanced Error Handling** -```typescript -// Add response transformation middleware -class ApiResponseTransformer { - static transform(response: any, expectedFields: string[]): T { - const missing = expectedFields.filter(field => !(field in response)); - if (missing.length > 0) { - console.warn(`Missing expected fields: ${missing.join(', ')}`); - } - return response; - } -} -``` - -## 🎯 Backend API Alignment Score - -| Service | Endpoint | Status | Score | Notes | -|---------|----------|--------|--------|-------| -| **Auth** | Registration | ✅ | 100% | Perfect alignment | -| **Auth** | Login | ✅ | 100% | Perfect alignment | -| **Tenant** | Create | ✅ | 100% | Perfect alignment | -| **Data** | Validation | ✅ | 100% | Perfect alignment | -| **Data** | Import | ⚠️ | 50% | Wrong endpoint called | -| **Data** | Products List | ⚠️ | 50% | Empty due to import issue | -| **Training** | Job Start | ✅ | 100% | Perfect alignment | -| **Training** | Job Status | ❌ | 0% | 404 error | -| **Forecasting** | Create | ❌ | 25% | Missing required fields | - -**Overall Score: 62.5%** - Good foundation with specific issues to address - -## 🚀 Action Items - -### High Priority -1. **Fix Data Import Endpoint** - Update frontend to call actual import endpoint -2. **Add Location Support to Forecasting** - Include required location field -3. **Investigate Training Status 404** - Debug timing or endpoint path issues - -### Medium Priority -1. **Add Response Transformation Layer** - Handle different response formats gracefully -2. **Implement Status Polling** - Add retry logic for async operations -3. **Enhanced Error Handling** - Better error messages and fallback strategies - -### Low Priority -1. **Add Request/Response Logging** - Better debugging capabilities -2. **Type Safety Improvements** - Ensure all responses match expected types -3. **Timeout Configuration** - Different timeouts for different operation types - -## 📊 Updated Test Results (After Fixes) - -After implementing the key fixes identified in this analysis, the frontend API simulation test showed **significant improvement**: - -### ✅ Test Results Summary -- **Before Fixes**: 62.5% success rate (5/8 tests passing) -- **After Fixes**: 75.0% success rate (6/8 tests passing) -- **Improvement**: +12.5 percentage points - -### 🔧 Fixes Applied -1. **✅ Fixed Location Field in Forecasting**: Added required `location` field to forecast requests -2. **✅ Identified Training Status Issue**: Confirmed it's a timing issue with background job execution -3. **✅ Verified Import Endpoint Design**: Found that frontend correctly uses file upload, simulation was testing wrong pattern - -### 🎯 Remaining Issues -1. **Training Status 404**: Background training job creates log record after initial status check - needs retry logic -2. **Products List Empty**: Depends on successful data import completion - will resolve once import works - -## 📊 Conclusion - -The frontend API abstraction layer demonstrates **excellent architectural design** and **strong alignment** with backend services. After implementing targeted fixes, we achieved **75% compatibility** with clear paths to reach **>90%**. - -### 🚀 Key Strengths -- **Perfect Authentication Flow**: 100% compatibility for user registration and login -- **Excellent Tenant Management**: Seamless tenant creation and management -- **Robust Data Validation**: Comprehensive validation with detailed feedback -- **Well-Designed Type System**: Frontend types align well with backend schemas - -### 🎯 Immediate Next Steps -1. **Add Retry Logic**: Implement exponential backoff for training status checks -2. **File Upload Testing**: Test actual file upload workflow in addition to JSON validation -3. **Background Job Monitoring**: Add WebSocket or polling for real-time status updates - -**Final Recommendation**: The frontend API abstraction layer is **production-ready** with excellent alignment. The identified improvements are optimizations rather than critical fixes. \ No newline at end of file diff --git a/debug_registration.js b/debug_registration.js deleted file mode 100644 index f8245c56..00000000 --- a/debug_registration.js +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -const http = require('http'); -const { URL } = require('url'); - -async function testRegistration() { - const registerData = { - email: `debug.${Date.now()}@bakery.com`, - password: 'TestPassword123!', - full_name: 'Debug Test User', - role: 'admin' - }; - - const bodyString = JSON.stringify(registerData); - console.log('Request body:', bodyString); - console.log('Content-Length:', Buffer.byteLength(bodyString, 'utf8')); - - const url = new URL('/api/v1/auth/register', 'http://localhost:8000'); - - const options = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'Frontend-Debug/1.0', - 'Content-Length': Buffer.byteLength(bodyString, 'utf8') - }, - }; - - return new Promise((resolve, reject) => { - console.log('Making request to:', url.href); - console.log('Headers:', options.headers); - - const req = http.request(url, options, (res) => { - console.log('Response status:', res.statusCode); - console.log('Response headers:', res.headers); - - let data = ''; - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - console.log('Response body:', data); - - try { - const parsedData = data ? JSON.parse(data) : {}; - - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(parsedData); - } else { - reject(new Error(`HTTP ${res.statusCode}: ${JSON.stringify(parsedData)}`)); - } - } catch (e) { - console.log('JSON parse error:', e.message); - reject(new Error(`HTTP ${res.statusCode}: ${data}`)); - } - }); - }); - - req.on('error', (error) => { - console.log('Request error:', error); - reject(error); - }); - - console.log('Writing body:', bodyString); - req.write(bodyString); - req.end(); - }); -} - -testRegistration() - .then(result => { - console.log('✅ Success:', result); - }) - .catch(error => { - console.log('❌ Error:', error.message); - }); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d37efe58..da7e180b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import LoginPage from './pages/auth/LoginPage'; import RegisterPage from './pages/auth/RegisterPage'; import OnboardingPage from './pages/onboarding/OnboardingPage'; import DashboardPage from './pages/dashboard/DashboardPage'; +import ProductionPage from './pages/production/ProductionPage'; import ForecastPage from './pages/forecast/ForecastPage'; import OrdersPage from './pages/orders/OrdersPage'; import SettingsPage from './pages/settings/SettingsPage'; @@ -24,7 +25,7 @@ import './i18n'; // Global styles import './styles/globals.css'; -type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'forecast' | 'orders' | 'settings'; +type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'reports' | 'orders' | 'production' | 'settings'; interface User { id: string; @@ -178,14 +179,20 @@ const App: React.FC = () => { // Main app pages with layout const pageComponent = () => { switch (appState.currentPage) { - case 'forecast': + case 'reports': return ; case 'orders': return ; + case 'production': + return ; case 'settings': return ; default: - return ; + return navigateTo('orders')} + onNavigateToReports={() => navigateTo('reports')} + onNavigateToProduction={() => navigateTo('production')} + />; } }; diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index facc29e4..a8873f52 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -9,7 +9,8 @@ import { LogOut, User, Bell, - ChevronDown + ChevronDown, + ChefHat } from 'lucide-react'; interface LayoutProps { @@ -38,9 +39,10 @@ const Layout: React.FC = ({ const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const navigation: NavigationItem[] = [ - { id: 'dashboard', label: 'Panel Principal', icon: Home, href: '/dashboard' }, - { id: 'forecast', label: 'Predicciones', icon: TrendingUp, href: '/forecast' }, + { id: 'dashboard', label: 'Inicio', icon: Home, href: '/dashboard' }, { id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' }, + { id: 'production', label: 'Producción', icon: ChefHat, href: '/production' }, + { id: 'reports', label: 'Informes', icon: TrendingUp, href: '/reports' }, { id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' }, ]; diff --git a/frontend/src/components/simple/CriticalAlerts.tsx b/frontend/src/components/simple/CriticalAlerts.tsx new file mode 100644 index 00000000..690ab6bb --- /dev/null +++ b/frontend/src/components/simple/CriticalAlerts.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { AlertTriangle, Cloud, Package, Clock, ChevronRight } from 'lucide-react'; + +export interface Alert { + id: string; + type: 'stock' | 'weather' | 'order' | 'production' | 'system'; + severity: 'high' | 'medium' | 'low'; + title: string; + description: string; + action?: string; + time?: string; +} + +interface CriticalAlertsProps { + alerts?: Alert[]; + onAlertClick?: (alertId: string) => void; + className?: string; +} + +const CriticalAlerts: React.FC = ({ + alerts = [ + { + id: '1', + type: 'stock', + severity: 'high', + title: 'Stock Bajo', + description: 'Pan integral: solo 5 unidades', + action: 'Hacer más', + time: '10:30' + }, + { + id: '2', + type: 'weather', + severity: 'medium', + title: 'Lluvia Esperada', + description: 'Precipitaciones 14:00 - 17:00', + action: 'Ajustar producción', + time: '14:00' + }, + { + id: '3', + type: 'order', + severity: 'low', + title: 'Pedido Especial', + description: 'Tarta de cumpleaños Ana - Viernes', + action: 'Ver detalles', + time: 'Viernes' + } + ], + onAlertClick, + className = '' +}) => { + const getAlertIcon = (type: Alert['type']) => { + switch (type) { + case 'stock': return Package; + case 'weather': return Cloud; + case 'order': return Clock; + case 'production': return AlertTriangle; + default: return AlertTriangle; + } + }; + + const getAlertColors = (severity: Alert['severity']) => { + switch (severity) { + case 'high': return { + bg: 'bg-red-50 border-red-200', + icon: 'text-red-600', + title: 'text-red-900', + description: 'text-red-700' + }; + case 'medium': return { + bg: 'bg-yellow-50 border-yellow-200', + icon: 'text-yellow-600', + title: 'text-yellow-900', + description: 'text-yellow-700' + }; + case 'low': return { + bg: 'bg-blue-50 border-blue-200', + icon: 'text-blue-600', + title: 'text-blue-900', + description: 'text-blue-700' + }; + } + }; + + const visibleAlerts = alerts.slice(0, 3); // Show max 3 alerts + + return ( +
+ {/* Header */} +
+

+ + Atención Requerida +

+ {alerts.length > 3 && ( + + +{alerts.length - 3} más + + )} +
+ + {/* Alerts List */} + {visibleAlerts.length > 0 ? ( +
+ {visibleAlerts.map((alert) => { + const IconComponent = getAlertIcon(alert.type); + const colors = getAlertColors(alert.severity); + + return ( +
onAlertClick?.(alert.id)} + className={`${colors.bg} border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-all duration-200`} + > +
+ + +
+
+
+

+ {alert.title} +

+

+ {alert.description} +

+ {alert.action && ( +

+ → {alert.action} +

+ )} +
+ +
+ {alert.time && ( + + {alert.time} + + )} + +
+
+
+
+
+ ); + })} +
+ ) : ( +
+
+ +
+

Todo bajo control

+

No hay alertas que requieran atención

+
+ )} + + {/* Quick Summary */} +
+
+ +
+ {alerts.filter(a => a.severity === 'high').length} Urgentes +
+ +
+ {alerts.filter(a => a.severity === 'medium').length} Importantes +
+
+ +
+
+ ); +}; + +export default CriticalAlerts; \ No newline at end of file diff --git a/frontend/src/components/simple/OrderSuggestions.tsx b/frontend/src/components/simple/OrderSuggestions.tsx new file mode 100644 index 00000000..9559fabe --- /dev/null +++ b/frontend/src/components/simple/OrderSuggestions.tsx @@ -0,0 +1,413 @@ +import React, { useState } from 'react'; +import { + ShoppingCart, + Calendar, + TrendingUp, + AlertTriangle, + CheckCircle, + Clock, + Plus, + Minus, + Eye, + ArrowRight +} from 'lucide-react'; + +// Types for order suggestions +export interface DailyOrderItem { + id: string; + product: string; + emoji: string; + suggestedQuantity: number; + currentQuantity: number; + unit: string; + urgency: 'high' | 'medium' | 'low'; + reason: string; + confidence: number; + supplier: string; + estimatedCost: number; + lastOrderDate: string; +} + +export interface WeeklyOrderItem { + id: string; + product: string; + emoji: string; + suggestedQuantity: number; + currentStock: number; + unit: string; + frequency: 'weekly' | 'biweekly'; + nextOrderDate: string; + supplier: string; + estimatedCost: number; + stockDays: number; // Days until stock runs out + confidence: number; +} + +interface OrderSuggestionsProps { + dailyOrders: DailyOrderItem[]; + weeklyOrders: WeeklyOrderItem[]; + onUpdateQuantity: (orderId: string, quantity: number, type: 'daily' | 'weekly') => void; + onCreateOrder: (items: (DailyOrderItem | WeeklyOrderItem)[], type: 'daily' | 'weekly') => void; + onViewDetails: () => void; + className?: string; +} + +const OrderSuggestions: React.FC = ({ + dailyOrders, + weeklyOrders, + onUpdateQuantity, + onCreateOrder, + onViewDetails, + className = '' +}) => { + const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily'); + const [selectedItems, setSelectedItems] = useState>(new Set()); + + const getUrgencyColor = (urgency: DailyOrderItem['urgency']) => { + switch (urgency) { + case 'high': + return 'bg-red-100 text-red-800 border-red-200'; + case 'medium': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case 'low': + return 'bg-green-100 text-green-800 border-green-200'; + } + }; + + const getUrgencyLabel = (urgency: DailyOrderItem['urgency']) => { + switch (urgency) { + case 'high': + return 'Urgente'; + case 'medium': + return 'Normal'; + case 'low': + return 'Bajo'; + } + }; + + const getStockStatusColor = (stockDays: number) => { + if (stockDays <= 2) return 'bg-red-100 text-red-800'; + if (stockDays <= 5) return 'bg-yellow-100 text-yellow-800'; + return 'bg-green-100 text-green-800'; + }; + + const handleQuantityChange = (itemId: string, delta: number, type: 'daily' | 'weekly') => { + const items = type === 'daily' ? dailyOrders : weeklyOrders; + const item = items.find(i => i.id === itemId); + if (item) { + const newQuantity = Math.max(0, item.suggestedQuantity + delta); + onUpdateQuantity(itemId, newQuantity, type); + } + }; + + const toggleItemSelection = (itemId: string) => { + const newSelected = new Set(selectedItems); + if (newSelected.has(itemId)) { + newSelected.delete(itemId); + } else { + newSelected.add(itemId); + } + setSelectedItems(newSelected); + }; + + const handleCreateSelectedOrders = () => { + if (activeTab === 'daily') { + const selectedDailyItems = dailyOrders.filter(item => selectedItems.has(item.id)); + if (selectedDailyItems.length > 0) { + onCreateOrder(selectedDailyItems, 'daily'); + } + } else { + const selectedWeeklyItems = weeklyOrders.filter(item => selectedItems.has(item.id)); + if (selectedWeeklyItems.length > 0) { + onCreateOrder(selectedWeeklyItems, 'weekly'); + } + } + setSelectedItems(new Set()); + }; + + const totalDailyCost = dailyOrders + .filter(item => selectedItems.has(item.id)) + .reduce((sum, item) => sum + item.estimatedCost, 0); + + const totalWeeklyCost = weeklyOrders + .filter(item => selectedItems.has(item.id)) + .reduce((sum, item) => sum + item.estimatedCost, 0); + + return ( +
+ {/* Header */} +
+
+

+ + Pedidos Sugeridos por IA +

+

+ Optimización inteligente basada en predicciones de demanda +

+
+ + +
+ + {/* Tabs */} +
+ + +
+ + {/* Daily Orders Tab */} + {activeTab === 'daily' && ( +
+
+
+ Pedidos para mañana desde la panadería central +
+
+ Total seleccionado: €{totalDailyCost.toFixed(2)} +
+
+ +
+ {dailyOrders.map((item) => ( +
+
+
+ toggleItemSelection(item.id)} + className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" + /> + {item.emoji} +
+
{item.product}
+
{item.supplier}
+
+
+ +
+ + {getUrgencyLabel(item.urgency)} + + +
+ + {item.suggestedQuantity} + + {item.unit} +
+ +
+
€{item.estimatedCost.toFixed(2)}
+
{item.confidence}% confianza
+
+
+
+ +
+ + {item.reason} +
+ + {item.currentQuantity > 0 && ( +
+ Stock actual: {item.currentQuantity} {item.unit} +
+ )} +
+ ))} +
+ + {selectedItems.size > 0 && ( +
+
+
+
+ {selectedItems.size} productos seleccionados +
+
+ Total estimado: €{totalDailyCost.toFixed(2)} +
+
+ +
+
+ )} +
+ )} + + {/* Weekly Orders Tab */} + {activeTab === 'weekly' && ( +
+
+
+ Pedidos semanales para ingredientes y suministros +
+
+ Total seleccionado: €{totalWeeklyCost.toFixed(2)} +
+
+ +
+ {weeklyOrders.map((item) => ( +
+
+
+ toggleItemSelection(item.id)} + className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" + /> + {item.emoji} +
+
{item.product}
+
{item.supplier}
+
+
+ +
+ + {item.stockDays} días de stock + + +
+ + {item.suggestedQuantity} + + {item.unit} +
+ +
+
€{item.estimatedCost.toFixed(2)}
+
{item.confidence}% confianza
+
+
+
+ +
+
+ Stock actual: {item.currentStock} {item.unit} +
+
+ Próximo pedido: {new Date(item.nextOrderDate).toLocaleDateString('es-ES')} +
+
+ +
+ + Frecuencia: {item.frequency === 'weekly' ? 'Semanal' : 'Quincenal'} +
+
+ ))} +
+ + {selectedItems.size > 0 && ( +
+
+
+
+ {selectedItems.size} productos seleccionados +
+
+ Total estimado: €{totalWeeklyCost.toFixed(2)} +
+
+ +
+
+ )} +
+ )} + + {/* Empty States */} + {activeTab === 'daily' && dailyOrders.length === 0 && ( +
+ +

No hay pedidos diarios sugeridos

+

El stock actual es suficiente para mañana

+
+ )} + + {activeTab === 'weekly' && weeklyOrders.length === 0 && ( +
+ +

No hay pedidos semanales pendientes

+

Todos los suministros están en stock

+
+ )} +
+ ); +}; + +export default OrderSuggestions; \ No newline at end of file diff --git a/frontend/src/components/simple/QuickActions.tsx b/frontend/src/components/simple/QuickActions.tsx new file mode 100644 index 00000000..65e3243c --- /dev/null +++ b/frontend/src/components/simple/QuickActions.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { + Phone, FileText, AlertTriangle, Package, + Euro, BarChart3, Settings, Calendar, + Plus, Clock +} from 'lucide-react'; + +export interface QuickAction { + id: string; + label: string; + icon: any; + description?: string; + variant: 'primary' | 'secondary' | 'urgent'; + badge?: string; +} + +interface QuickActionsProps { + actions?: QuickAction[]; + onActionClick?: (actionId: string) => void; + className?: string; +} + +const QuickActions: React.FC = ({ + actions = [ + { + id: 'call_supplier', + label: 'Llamar Proveedor', + icon: Phone, + description: 'Contactar proveedor principal', + variant: 'urgent', + badge: 'Urgente' + }, + { + id: 'record_sale', + label: 'Anotar Venta', + icon: FileText, + description: 'Registrar venta manual', + variant: 'primary' + }, + { + id: 'stock_issue', + label: 'Problema Stock', + icon: AlertTriangle, + description: 'Reportar falta de producto', + variant: 'urgent' + }, + { + id: 'view_orders', + label: 'Ver Pedidos', + icon: Package, + description: 'Revisar pedidos especiales', + variant: 'secondary', + badge: '3' + }, + { + id: 'close_register', + label: 'Cerrar Caja', + icon: Euro, + description: 'Arqueo de caja diario', + variant: 'primary' + }, + { + id: 'view_sales', + label: 'Ver Ventas', + icon: BarChart3, + description: 'Resumen de ventas del día', + variant: 'secondary' + } + ], + onActionClick, + className = '' +}) => { + const getActionStyles = (variant: QuickAction['variant']) => { + switch (variant) { + case 'primary': + return { + button: 'bg-blue-600 hover:bg-blue-700 text-white border-blue-600', + icon: 'text-white' + }; + case 'urgent': + return { + button: 'bg-red-600 hover:bg-red-700 text-white border-red-600', + icon: 'text-white' + }; + case 'secondary': + return { + button: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300', + icon: 'text-gray-600' + }; + } + }; + + return ( +
+ {/* Header */} +
+

+ + Acciones Rápidas +

+ + Tareas comunes + +
+ + {/* Actions Grid */} +
+ {actions.map((action) => { + const IconComponent = action.icon; + const styles = getActionStyles(action.variant); + + return ( + + ); + })} +
+ + {/* Quick Stats */} +
+
+ +
+ {actions.filter(a => a.variant === 'urgent').length} Urgentes +
+ +
+ {actions.filter(a => a.variant === 'primary').length} Principales +
+
+ + {/* Add Action */} + +
+ + {/* Keyboard Shortcuts Hint */} +
+

+ 💡 Usa Ctrl + K para acceso rápido por teclado +

+
+
+ ); +}; + +export default QuickActions; \ No newline at end of file diff --git a/frontend/src/components/simple/QuickOverview.tsx b/frontend/src/components/simple/QuickOverview.tsx new file mode 100644 index 00000000..9a2b129b --- /dev/null +++ b/frontend/src/components/simple/QuickOverview.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Package, BarChart3, Cloud, ChevronRight, Calendar, TrendingUp } from 'lucide-react'; + +interface QuickOverviewProps { + onNavigateToOrders?: () => void; + onNavigateToReports?: () => void; + className?: string; +} + +const QuickOverview: React.FC = ({ + onNavigateToOrders, + onNavigateToReports, + className = '' +}) => { + return ( +
+ {/* Orders Summary */} +
+
+

+ + Pedidos +

+ +
+ +
+
+
+ + Tarta Ana +
+ Viernes +
+ +
+
+ + Cumple Sara +
+ Sábado +
+ +
+ 2 pedidos especiales esta semana +
+
+
+ + {/* Weekly Summary */} +
+
+

+ + Esta Semana +

+ +
+ +
+
+ Ventas totales + €1,940 +
+ +
+ Mejor día + Martes (+18%) +
+ +
+ Producto top + 🥐 Croissants +
+ +
+
+ + +8% vs semana anterior +
+
+
+
+ + {/* Weather & Context */} +
+
+

+ + Clima & Contexto +

+
+ +
+
+
+
Lluvia esperada
+
14:00 - 17:00
+
+
+
🌧️
+
12°C
+
+
+ +
+ Impacto estimado + -15% ventas +
+ +
+ Predicciones ya ajustadas +
+
+
+
+ ); +}; + +export default QuickOverview; \ No newline at end of file diff --git a/frontend/src/components/simple/TodayProduction.tsx b/frontend/src/components/simple/TodayProduction.tsx new file mode 100644 index 00000000..72564e79 --- /dev/null +++ b/frontend/src/components/simple/TodayProduction.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { CheckCircle2, Clock, Loader2, Calendar, Plus, Minus } from 'lucide-react'; + +export interface ProductionItem { + id: string; + product: string; + emoji: string; + quantity: number; + status: 'completed' | 'in_progress' | 'pending'; + scheduledTime: string; + completedTime?: string; + confidence?: number; +} + +interface TodayProductionProps { + items?: ProductionItem[]; + onUpdateQuantity?: (itemId: string, newQuantity: number) => void; + onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void; + className?: string; +} + +const TodayProduction: React.FC = ({ + items = [ + { + id: '1', + product: 'Croissants', + emoji: '🥐', + quantity: 45, + status: 'completed', + scheduledTime: '06:00', + completedTime: '06:30', + confidence: 92 + }, + { + id: '2', + product: 'Pan integral', + emoji: '🍞', + quantity: 30, + status: 'in_progress', + scheduledTime: '07:30', + confidence: 87 + }, + { + id: '3', + product: 'Magdalenas', + emoji: '🧁', + quantity: 25, + status: 'pending', + scheduledTime: '09:00', + confidence: 78 + }, + { + id: '4', + product: 'Empanadas', + emoji: '🥟', + quantity: 20, + status: 'pending', + scheduledTime: '10:30', + confidence: 85 + } + ], + onUpdateQuantity, + onUpdateStatus, + className = '' +}) => { + const getStatusIcon = (status: ProductionItem['status']) => { + switch (status) { + case 'completed': return ; + case 'in_progress': return ; + case 'pending': return ; + } + }; + + const getStatusText = (status: ProductionItem['status']) => { + switch (status) { + case 'completed': return 'Listo'; + case 'in_progress': return 'Haciendo'; + case 'pending': return 'Pendiente'; + } + }; + + const getStatusColors = (status: ProductionItem['status']) => { + switch (status) { + case 'completed': return 'bg-green-50 border-green-200'; + case 'in_progress': return 'bg-blue-50 border-blue-200'; + case 'pending': return 'bg-gray-50 border-gray-200'; + } + }; + + const completedCount = items.filter(item => item.status === 'completed').length; + const inProgressCount = items.filter(item => item.status === 'in_progress').length; + const pendingCount = items.filter(item => item.status === 'pending').length; + + return ( +
+ {/* Header */} +
+

+ + Producir Hoy +

+
+ {new Date().toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' })} +
+
+ + {/* Progress Summary */} +
+
+
{completedCount}
+
Listos
+
+
+
{inProgressCount}
+
En curso
+
+
+
{pendingCount}
+
Pendientes
+
+
+
{items.reduce((sum, item) => sum + item.quantity, 0)}
+
Total uds
+
+
+ + {/* Production Items */} +
+ {items.map((item) => ( +
+
+ {/* Status Icon */} +
+ {getStatusIcon(item.status)} +
+ + {/* Product Info */} +
+
+ {item.emoji} +

{item.product}

+ {item.confidence && ( + + ({item.confidence}% confianza) + + )} +
+ +
+ + + {item.status === 'completed' && item.completedTime + ? `Listo a las ${item.completedTime}` + : `Programado ${item.scheduledTime}`} + + + {getStatusText(item.status)} + +
+
+ + {/* Quantity Controls */} +
+
+ + + + {item.quantity} + + + +
+
uds
+
+ + {/* Action Button */} + {item.status !== 'completed' && ( +
+ +
+ )} +
+
+ ))} +
+ + {/* Quick Actions */} +
+ + +
+
+ ); +}; + +export default TodayProduction; \ No newline at end of file diff --git a/frontend/src/components/simple/TodayRevenue.tsx b/frontend/src/components/simple/TodayRevenue.tsx new file mode 100644 index 00000000..f81d1b4c --- /dev/null +++ b/frontend/src/components/simple/TodayRevenue.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { TrendingUp, TrendingDown, Euro } from 'lucide-react'; + +interface TodayRevenueProps { + currentRevenue: number; + previousRevenue: number; + dailyTarget: number; + className?: string; +} + +const TodayRevenue: React.FC = ({ + currentRevenue = 287.50, + previousRevenue = 256.25, + dailyTarget = 350, + className = '' +}) => { + const changeAmount = currentRevenue - previousRevenue; + const changePercentage = ((changeAmount / previousRevenue) * 100); + const remainingToTarget = Math.max(0, dailyTarget - currentRevenue); + const targetProgress = (currentRevenue / dailyTarget) * 100; + const isPositive = changeAmount >= 0; + + return ( +
+ {/* Header */} +
+

+ + Ingresos de Hoy +

+ + {new Date().toLocaleDateString('es-ES')} + +
+ + {/* Main Revenue */} +
+
+ + €{currentRevenue.toFixed(2)} + +
+ {isPositive ? ( + + ) : ( + + )} + {isPositive ? '+' : ''}{changePercentage.toFixed(1)}% +
+
+

+ {isPositive ? '+' : ''} €{changeAmount.toFixed(2)} vs ayer +

+
+ + {/* Target Progress */} +
+
+ Meta del día: €{dailyTarget} + {targetProgress.toFixed(1)}% +
+
+
= 100 ? 'bg-green-500' : + targetProgress >= 80 ? 'bg-yellow-500' : 'bg-blue-500' + }`} + style={{ width: `${Math.min(targetProgress, 100)}%` }} + >
+
+ {remainingToTarget > 0 && ( +

+ Faltan €{remainingToTarget.toFixed(2)} para la meta +

+ )} +
+ + {/* Quick Stats */} +
+
+

Esta Semana

+

€1,940

+
+
+

Promedio/Día

+

€{(1940/7).toFixed(0)}

+
+
+
+ ); +}; + +export default TodayRevenue; \ No newline at end of file diff --git a/frontend/src/components/ui/AIInsightsFeed.tsx b/frontend/src/components/ui/AIInsightsFeed.tsx new file mode 100644 index 00000000..a7a8a514 --- /dev/null +++ b/frontend/src/components/ui/AIInsightsFeed.tsx @@ -0,0 +1,438 @@ +import { useState, useEffect } from 'react'; +import { Brain, TrendingUp, TrendingDown, Star, Clock, Eye, EyeOff, MoreHorizontal } from 'lucide-react'; + +interface AIInsight { + id: string; + type: 'trend' | 'opportunity' | 'warning' | 'recommendation' | 'achievement'; + title: string; + description: string; + confidence: number; + impact: 'high' | 'medium' | 'low'; + category: 'demand' | 'revenue' | 'efficiency' | 'quality' | 'customer'; + timestamp: string; + data?: { + trend?: { + direction: 'up' | 'down'; + percentage: number; + period: string; + }; + revenue?: { + amount: number; + comparison: string; + }; + actionable?: { + action: string; + expectedImpact: string; + }; + }; + isRead?: boolean; + isStarred?: boolean; +} + +interface AIInsightsFeedProps { + insights?: AIInsight[]; + onInsightAction?: (insightId: string, action: 'read' | 'star' | 'dismiss') => void; + maxItems?: number; + showFilters?: boolean; + className?: string; +} + +const AIInsightsFeed: React.FC = ({ + insights: propInsights, + onInsightAction, + maxItems = 10, + showFilters = true, + className = '' +}) => { + const [insights, setInsights] = useState([]); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [selectedImpact, setSelectedImpact] = useState('all'); + const [showOnlyUnread, setShowOnlyUnread] = useState(false); + + // Generate realistic AI insights if none provided + useEffect(() => { + if (propInsights) { + setInsights(propInsights); + } else { + setInsights(generateSampleInsights()); + } + }, [propInsights]); + + const generateSampleInsights = (): AIInsight[] => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + + return [ + { + id: '1', + type: 'opportunity', + title: 'Oportunidad: Incremento en demanda de tartas', + description: 'Los datos muestran un aumento del 23% en la demanda de tartas los viernes. Considera aumentar la producción para maximizar ingresos.', + confidence: 87, + impact: 'high', + category: 'revenue', + timestamp: now.toISOString(), + data: { + trend: { direction: 'up', percentage: 23, period: 'últimas 3 semanas' }, + actionable: { action: 'Aumentar producción de tartas los viernes', expectedImpact: '+€180/semana' } + }, + isRead: false, + isStarred: false + }, + { + id: '2', + type: 'warning', + title: 'Alerta: Posible desperdicio de magdalenas', + description: 'Las magdalenas tienen una tasa de venta del 67% los martes. Reducir la producción podría ahorrar €45 semanales.', + confidence: 78, + impact: 'medium', + category: 'efficiency', + timestamp: yesterday.toISOString(), + data: { + revenue: { amount: 45, comparison: 'ahorro semanal estimado' }, + actionable: { action: 'Reducir producción de magdalenas los martes', expectedImpact: '-€45 desperdicio' } + }, + isRead: true, + isStarred: false + }, + { + id: '3', + type: 'trend', + title: 'Tendencia: Croissants más populares por las mañanas', + description: 'El 78% de las ventas de croissants ocurren antes de las 11 AM. Considera reorganizar la producción matutina.', + confidence: 92, + impact: 'medium', + category: 'efficiency', + timestamp: yesterday.toISOString(), + data: { + trend: { direction: 'up', percentage: 78, period: 'horario matutino' }, + actionable: { action: 'Priorizar croissants en producción matutina', expectedImpact: 'Mejor disponibilidad' } + }, + isRead: false, + isStarred: true + }, + { + id: '4', + type: 'achievement', + title: '¡Éxito! Predicciones de ayer fueron 94% precisas', + description: 'Las predicciones de demanda de ayer tuvieron una precisión del 94%, resultando en ventas óptimas sin desperdicios.', + confidence: 94, + impact: 'high', + category: 'quality', + timestamp: yesterday.toISOString(), + data: { + revenue: { amount: 127, comparison: 'ingresos adicionales por precisión' } + }, + isRead: false, + isStarred: false + }, + { + id: '5', + type: 'recommendation', + title: 'Recomendación: Nuevo producto para fin de semana', + description: 'Los datos de fin de semana sugieren que los clientes buscan productos especiales. Una tarta de temporada podría generar €200 adicionales.', + confidence: 72, + impact: 'high', + category: 'revenue', + timestamp: twoDaysAgo.toISOString(), + data: { + revenue: { amount: 200, comparison: 'ingresos potenciales fin de semana' }, + actionable: { action: 'Introducir tarta especial de fin de semana', expectedImpact: '+€200/semana' } + }, + isRead: true, + isStarred: false + }, + { + id: '6', + type: 'trend', + title: 'Patrón meteorológico: Lluvia afecta ventas -15%', + description: 'Los días lluviosos reducen las ventas en promedio 15%. El algoritmo ahora ajusta automáticamente las predicciones.', + confidence: 88, + impact: 'medium', + category: 'demand', + timestamp: twoDaysAgo.toISOString(), + data: { + trend: { direction: 'down', percentage: 15, period: 'días lluviosos' }, + actionable: { action: 'Producción automáticamente ajustada', expectedImpact: 'Menos desperdicio' } + }, + isRead: true, + isStarred: false + } + ]; + }; + + const handleInsightAction = (insightId: string, action: 'read' | 'star' | 'dismiss') => { + setInsights(prev => prev.map(insight => { + if (insight.id === insightId) { + switch (action) { + case 'read': + return { ...insight, isRead: true }; + case 'star': + return { ...insight, isStarred: !insight.isStarred }; + case 'dismiss': + return insight; // In a real app, this might remove the insight + } + } + return insight; + })); + + onInsightAction?.(insightId, action); + }; + + const filteredInsights = insights.filter(insight => { + if (selectedCategory !== 'all' && insight.category !== selectedCategory) return false; + if (selectedImpact !== 'all' && insight.impact !== selectedImpact) return false; + if (showOnlyUnread && insight.isRead) return false; + return true; + }).slice(0, maxItems); + + const getInsightIcon = (type: AIInsight['type']) => { + switch (type) { + case 'trend': return TrendingUp; + case 'opportunity': return Star; + case 'warning': return TrendingDown; + case 'recommendation': return Brain; + case 'achievement': return Star; + default: return Brain; + } + }; + + const getInsightColors = (type: AIInsight['type'], impact: AIInsight['impact']) => { + const baseColors = { + trend: 'blue', + opportunity: 'green', + warning: 'yellow', + recommendation: 'purple', + achievement: 'emerald' + }; + + const color = baseColors[type] || 'gray'; + const intensity = impact === 'high' ? '600' : impact === 'medium' ? '500' : '400'; + + return { + background: `bg-${color}-50`, + border: `border-${color}-200`, + icon: `text-${color}-${intensity}`, + badge: impact === 'high' ? `bg-${color}-100 text-${color}-800` : + impact === 'medium' ? `bg-${color}-50 text-${color}-700` : + `bg-gray-100 text-gray-600` + }; + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60)); + + if (diffHours < 1) return 'Hace unos minutos'; + if (diffHours < 24) return `Hace ${diffHours}h`; + if (diffHours < 48) return 'Ayer'; + return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' }); + }; + + const unreadCount = insights.filter(i => !i.isRead).length; + + return ( +
+ {/* Header */} +
+
+
+ +
+

+ Insights de IA +

+

+ Recomendaciones inteligentes para tu negocio + {unreadCount > 0 && ( + + {unreadCount} nuevos + + )} +

+
+
+
+ + {/* Filters */} + {showFilters && ( +
+ + + + + +
+ )} +
+ + {/* Insights List */} +
+ {filteredInsights.map((insight) => { + const IconComponent = getInsightIcon(insight.type); + const colors = getInsightColors(insight.type, insight.impact); + + return ( +
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+
+

+ {insight.title} +

+

+ {insight.description} +

+ + {/* Metadata */} +
+ + + {formatTimestamp(insight.timestamp)} + + + + {insight.impact === 'high' ? 'Alto' : insight.impact === 'medium' ? 'Medio' : 'Bajo'} impacto + + + + {insight.confidence}% confianza + +
+ + {/* Data Insights */} + {insight.data && ( +
+ {insight.data.trend && ( +
+ {insight.data.trend.direction === 'up' ? ( + + ) : ( + + )} + + {insight.data.trend.percentage}% {insight.data.trend.direction === 'up' ? 'aumento' : 'reducción'} + + + en {insight.data.trend.period} + +
+ )} + + {insight.data.revenue && ( +
+ €{insight.data.revenue.amount} + {insight.data.revenue.comparison} +
+ )} + + {insight.data.actionable && ( +
+
{insight.data.actionable.action}
+
{insight.data.actionable.expectedImpact}
+
+ )} +
+ )} +
+ + {/* Actions */} +
+ + + + + +
+
+
+
+
+ ); + })} + + {filteredInsights.length === 0 && ( +
+ +

No hay insights disponibles

+

+ Los insights aparecerán aquí conforme el sistema analice tus datos +

+
+ )} +
+ + {/* Footer */} + {filteredInsights.length > 0 && ( +
+
+

+ Mostrando {filteredInsights.length} de {insights.length} insights +

+ +
+
+ )} +
+ ); +}; + +export default AIInsightsFeed; \ No newline at end of file diff --git a/frontend/src/components/ui/AlertCard.tsx b/frontend/src/components/ui/AlertCard.tsx new file mode 100644 index 00000000..3fb54a8e --- /dev/null +++ b/frontend/src/components/ui/AlertCard.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { AlertTriangle, TrendingDown, Package, Clock, Euro } from 'lucide-react'; + +export interface BusinessAlert { + id: string; + type: 'stockout_risk' | 'overstock' | 'revenue_loss' | 'quality_risk' | 'weather_impact'; + severity: 'low' | 'medium' | 'high' | 'critical'; + product: string; + message: string; + action: string; + impact?: { + type: 'revenue' | 'units' | 'percentage'; + value: number; + currency?: string; + }; + urgency?: 'immediate' | 'today' | 'this_week'; +} + +interface AlertCardProps { + alert: BusinessAlert; + onAction?: (alertId: string, actionType: string) => void; +} + +const getSeverityConfig = (severity: BusinessAlert['severity']) => { + switch (severity) { + case 'critical': + return { + bgColor: 'bg-red-50', + borderColor: 'border-red-200', + iconColor: 'text-red-600', + textColor: 'text-red-900', + actionColor: 'bg-red-100 hover:bg-red-200 text-red-800' + }; + case 'high': + return { + bgColor: 'bg-orange-50', + borderColor: 'border-orange-200', + iconColor: 'text-orange-600', + textColor: 'text-orange-900', + actionColor: 'bg-orange-100 hover:bg-orange-200 text-orange-800' + }; + case 'medium': + return { + bgColor: 'bg-yellow-50', + borderColor: 'border-yellow-200', + iconColor: 'text-yellow-600', + textColor: 'text-yellow-900', + actionColor: 'bg-yellow-100 hover:bg-yellow-200 text-yellow-800' + }; + default: + return { + bgColor: 'bg-blue-50', + borderColor: 'border-blue-200', + iconColor: 'text-blue-600', + textColor: 'text-blue-900', + actionColor: 'bg-blue-100 hover:bg-blue-200 text-blue-800' + }; + } +}; + +const getAlertIcon = (type: BusinessAlert['type']) => { + switch (type) { + case 'stockout_risk': + return Package; + case 'overstock': + return TrendingDown; + case 'revenue_loss': + return Euro; + case 'quality_risk': + return Clock; + case 'weather_impact': + return AlertTriangle; + default: + return AlertTriangle; + } +}; + +const getUrgencyLabel = (urgency?: BusinessAlert['urgency']) => { + switch (urgency) { + case 'immediate': + return { label: 'URGENTE', color: 'bg-red-100 text-red-800' }; + case 'today': + return { label: 'HOY', color: 'bg-orange-100 text-orange-800' }; + case 'this_week': + return { label: 'ESTA SEMANA', color: 'bg-blue-100 text-blue-800' }; + default: + return null; + } +}; + +const AlertCard: React.FC = ({ alert, onAction }) => { + const config = getSeverityConfig(alert.severity); + const Icon = getAlertIcon(alert.type); + const urgencyInfo = getUrgencyLabel(alert.urgency); + + const handleAction = () => { + onAction?.(alert.id, 'primary_action'); + }; + + return ( +
+
+
+ +
+ +
+
+
+
+

+ {alert.product} +

+ {urgencyInfo && ( + + {urgencyInfo.label} + + )} +
+ +

+ {alert.message} +

+ + {alert.impact && ( +
+ {alert.impact.type === 'revenue' && ( + <>Impacto: -{alert.impact.value}{alert.impact.currency || '€'} + )} + {alert.impact.type === 'units' && ( + <>Unidades afectadas: {alert.impact.value} + )} + {alert.impact.type === 'percentage' && ( + <>Reducción estimada: {alert.impact.value}% + )} +
+ )} +
+
+ +
+ +
+
+
+
+ ); +}; + +export default AlertCard; \ No newline at end of file diff --git a/frontend/src/components/ui/CompetitiveBenchmarks.tsx b/frontend/src/components/ui/CompetitiveBenchmarks.tsx new file mode 100644 index 00000000..c255c127 --- /dev/null +++ b/frontend/src/components/ui/CompetitiveBenchmarks.tsx @@ -0,0 +1,408 @@ +import { useState } from 'react'; +import { BarChart3, TrendingUp, TrendingDown, Award, Target, Eye, EyeOff } from 'lucide-react'; + +interface BenchmarkMetric { + id: string; + name: string; + yourValue: number; + industryAverage: number; + topPerformers: number; + unit: string; + description: string; + category: 'efficiency' | 'revenue' | 'waste' | 'customer' | 'quality'; + trend: 'improving' | 'declining' | 'stable'; + percentile: number; // Your position (0-100) + insights: string[]; +} + +interface CompetitiveBenchmarksProps { + metrics?: BenchmarkMetric[]; + location?: string; // e.g., "Madrid Centro" + showSensitiveData?: boolean; + className?: string; +} + +const CompetitiveBenchmarks: React.FC = ({ + metrics: propMetrics, + location = "Madrid Centro", + showSensitiveData = true, + className = '' +}) => { + const [selectedCategory, setSelectedCategory] = useState('all'); + const [showDetails, setShowDetails] = useState(showSensitiveData); + + // Sample benchmark data (anonymized) + const defaultMetrics: BenchmarkMetric[] = [ + { + id: 'forecast_accuracy', + name: 'Precisión de Predicciones', + yourValue: 87.2, + industryAverage: 72.5, + topPerformers: 94.1, + unit: '%', + description: 'Qué tan precisas son las predicciones vs. ventas reales', + category: 'quality', + trend: 'improving', + percentile: 85, + insights: [ + 'Superioridad del 15% vs. promedio de la industria', + 'Solo 7 puntos por debajo de los mejores del sector', + 'Mejora consistente en los últimos 3 meses' + ] + }, + { + id: 'waste_percentage', + name: 'Porcentaje de Desperdicio', + yourValue: 8.3, + industryAverage: 12.7, + topPerformers: 4.2, + unit: '%', + description: 'Productos no vendidos como % del total producido', + category: 'waste', + trend: 'improving', + percentile: 78, + insights: [ + '35% menos desperdicio que el promedio', + 'Oportunidad: reducir 4 puntos más para llegar al top', + 'Ahorro de ~€230/mes vs. promedio de la industria' + ] + }, + { + id: 'revenue_per_sqm', + name: 'Ingresos por m²', + yourValue: 2847, + industryAverage: 2134, + topPerformers: 4521, + unit: '€/mes', + description: 'Ingresos mensuales por metro cuadrado de local', + category: 'revenue', + trend: 'stable', + percentile: 73, + insights: [ + '33% más eficiente en generación de ingresos', + 'Potencial de crecimiento: +59% para alcanzar el top', + 'Excelente aprovechamiento del espacio' + ] + }, + { + id: 'customer_retention', + name: 'Retención de Clientes', + yourValue: 68, + industryAverage: 61, + topPerformers: 84, + unit: '%', + description: 'Clientes que regresan al menos una vez por semana', + category: 'customer', + trend: 'improving', + percentile: 67, + insights: [ + '11% mejor retención que la competencia', + 'Oportunidad: programas de fidelización podrían sumar 16 puntos', + 'Base de clientes sólida y leal' + ] + }, + { + id: 'production_efficiency', + name: 'Eficiencia de Producción', + yourValue: 1.8, + industryAverage: 2.3, + topPerformers: 1.2, + unit: 'h/100 unidades', + description: 'Tiempo promedio para producir 100 unidades', + category: 'efficiency', + trend: 'improving', + percentile: 71, + insights: [ + '22% más rápido que el promedio', + 'Excelente optimización de procesos', + 'Margen para mejora: -33% para ser top performer' + ] + }, + { + id: 'profit_margin', + name: 'Margen de Ganancia', + yourValue: 32.5, + industryAverage: 28.1, + topPerformers: 41.7, + unit: '%', + description: 'Ganancia neta como % de los ingresos totales', + category: 'revenue', + trend: 'stable', + percentile: 69, + insights: [ + '16% más rentable que la competencia', + 'Sólida gestión de costos', + 'Oportunidad: optimizar ingredientes premium' + ] + } + ]; + + const metrics = propMetrics || defaultMetrics; + + const categories = [ + { id: 'all', name: 'Todas', count: metrics.length }, + { id: 'revenue', name: 'Ingresos', count: metrics.filter(m => m.category === 'revenue').length }, + { id: 'efficiency', name: 'Eficiencia', count: metrics.filter(m => m.category === 'efficiency').length }, + { id: 'waste', name: 'Desperdicio', count: metrics.filter(m => m.category === 'waste').length }, + { id: 'customer', name: 'Clientes', count: metrics.filter(m => m.category === 'customer').length }, + { id: 'quality', name: 'Calidad', count: metrics.filter(m => m.category === 'quality').length } + ]; + + const filteredMetrics = metrics.filter(metric => + selectedCategory === 'all' || metric.category === selectedCategory + ); + + const getPerformanceLevel = (percentile: number) => { + if (percentile >= 90) return { label: 'Excelente', color: 'text-green-600', bg: 'bg-green-50' }; + if (percentile >= 75) return { label: 'Bueno', color: 'text-blue-600', bg: 'bg-blue-50' }; + if (percentile >= 50) return { label: 'Promedio', color: 'text-yellow-600', bg: 'bg-yellow-50' }; + return { label: 'Mejora Necesaria', color: 'text-red-600', bg: 'bg-red-50' }; + }; + + const getTrendIcon = (trend: string) => { + switch (trend) { + case 'improving': return ; + case 'declining': return ; + default: return
; + } + }; + + const getComparisonPercentage = (yourValue: number, compareValue: number, isLowerBetter = false) => { + const diff = isLowerBetter + ? ((compareValue - yourValue) / compareValue) * 100 + : ((yourValue - compareValue) / compareValue) * 100; + return { + value: Math.abs(diff), + isPositive: diff > 0 + }; + }; + + const isLowerBetter = (metricId: string) => { + return ['waste_percentage', 'production_efficiency'].includes(metricId); + }; + + const averagePercentile = Math.round(metrics.reduce((sum, m) => sum + m.percentile, 0) / metrics.length); + + return ( +
+ {/* Header */} +
+
+
+ +
+

+ Benchmarks Competitivos +

+

+ Comparación anónima con panaderías similares en {location} +

+
+
+ +
+ {/* Overall Score */} +
+
+ {averagePercentile} +
+
Percentil General
+
+ + {/* Toggle Details */} + +
+
+ + {/* Performance Summary */} +
+
+
+ {metrics.filter(m => m.percentile >= 75).length} +
+
Métricas Top 25%
+
+
+
+ {metrics.filter(m => m.trend === 'improving').length} +
+
En Mejora
+
+
+
+ {metrics.filter(m => m.percentile < 50).length} +
+
Áreas de Oportunidad
+
+
+ + {/* Category Filters */} +
+ {categories.map(category => ( + + ))} +
+
+ + {/* Metrics List */} +
+ {filteredMetrics.map(metric => { + const performance = getPerformanceLevel(metric.percentile); + const vsAverage = getComparisonPercentage( + metric.yourValue, + metric.industryAverage, + isLowerBetter(metric.id) + ); + const vsTop = getComparisonPercentage( + metric.yourValue, + metric.topPerformers, + isLowerBetter(metric.id) + ); + + return ( +
+
+
+
+

+ {metric.name} +

+ {getTrendIcon(metric.trend)} + + {performance.label} + +
+

{metric.description}

+
+ +
+
+ {metric.yourValue.toLocaleString('es-ES')}{metric.unit} +
+
Tu Resultado
+
+
+ + {/* Comparison Bars */} +
+ {/* Your Performance */} +
+
+ Tu Rendimiento + Percentil {metric.percentile} +
+
+
+
+
+ + {/* Industry Average */} +
+
+ Promedio Industria + + {metric.industryAverage.toLocaleString('es-ES')}{metric.unit} + + ({vsAverage.isPositive ? '+' : '-'}{vsAverage.value.toFixed(1)}%) + + +
+
+
+
+
+ + {/* Top Performers */} +
+
+ + + Top Performers + + + {metric.topPerformers.toLocaleString('es-ES')}{metric.unit} + + ({vsTop.isPositive ? '+' : '-'}{vsTop.value.toFixed(1)}%) + + +
+
+
+
+
+
+ + {/* Insights */} + {showDetails && ( +
+
+ + Insights Clave: +
+
    + {metric.insights.map((insight, index) => ( +
  • +
    + {insight} +
  • + ))} +
+
+ )} +
+ ); + })} +
+ + {filteredMetrics.length === 0 && ( +
+ +

No hay métricas disponibles

+

+ Los benchmarks aparecerán cuando haya suficientes datos +

+
+ )} + + {/* Disclaimer */} +
+
+
+ 🔒 Privacidad: Todos los datos están anonimizados. + Solo se comparten métricas agregadas de panaderías similares en tamaño y ubicación. +
+
+
+
+ ); +}; + +export default CompetitiveBenchmarks; \ No newline at end of file diff --git a/frontend/src/components/ui/DemandHeatmap.tsx b/frontend/src/components/ui/DemandHeatmap.tsx new file mode 100644 index 00000000..575402c2 --- /dev/null +++ b/frontend/src/components/ui/DemandHeatmap.tsx @@ -0,0 +1,287 @@ +import { useState } from 'react'; +import { ChevronLeft, ChevronRight, Calendar, TrendingUp, Eye } from 'lucide-react'; + +export interface DayDemand { + date: string; + demand: number; + isToday?: boolean; + isForecast?: boolean; + products?: Array<{ + name: string; + demand: number; + confidence: 'high' | 'medium' | 'low'; + }>; +} + +export interface WeekData { + weekStart: string; + days: DayDemand[]; +} + +interface DemandHeatmapProps { + data: WeekData[]; + selectedProduct?: string; + onDateClick?: (date: string) => void; + className?: string; +} + +const DemandHeatmap: React.FC = ({ + data, + selectedProduct, + onDateClick, + className = '' +}) => { + const [currentWeekIndex, setCurrentWeekIndex] = useState(0); + const [selectedDate, setSelectedDate] = useState(null); + + const maxDemand = Math.max( + ...data.flatMap(week => week.days.map(day => day.demand)) + ); + + const getDemandIntensity = (demand: number) => { + const intensity = demand / maxDemand; + if (intensity > 0.8) return 'bg-red-500'; + if (intensity > 0.6) return 'bg-orange-500'; + if (intensity > 0.4) return 'bg-yellow-500'; + if (intensity > 0.2) return 'bg-green-500'; + return 'bg-gray-200'; + }; + + const getDemandLabel = (demand: number) => { + const intensity = demand / maxDemand; + if (intensity > 0.8) return 'Muy Alta'; + if (intensity > 0.6) return 'Alta'; + if (intensity > 0.4) return 'Media'; + if (intensity > 0.2) return 'Baja'; + return 'Muy Baja'; + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.getDate().toString(); + }; + + const formatWeekRange = (weekStart: string) => { + const start = new Date(weekStart); + const end = new Date(start); + end.setDate(end.getDate() + 6); + + return `${start.getDate()}-${end.getDate()} ${start.toLocaleDateString('es-ES', { month: 'short' })}`; + }; + + const handleDateClick = (date: string) => { + setSelectedDate(date); + onDateClick?.(date); + }; + + const currentWeek = data[currentWeekIndex]; + const selectedDay = selectedDate ? + data.flatMap(w => w.days).find(d => d.date === selectedDate) : null; + + return ( +
+
+
+

+ + Mapa de Calor de Demanda +

+

+ Patrones de demanda visual por día + {selectedProduct && ` - ${selectedProduct}`} +

+
+ +
+ + + + {currentWeek ? formatWeekRange(currentWeek.weekStart) : 'Esta Semana'} + + + +
+
+ + {/* Heatmap Grid */} +
+ {['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(day => ( +
+ {day} +
+ ))} + + {currentWeek?.days.map(day => ( + + ))} +
+ + {/* Legend */} +
+
+ Demanda: +
+
+ Muy Baja +
+
+
+ Baja +
+
+
+ Media +
+
+
+ Alta +
+
+
+ Muy Alta +
+
+ +
+
+
+ Predicción +
+
+
+ Hoy +
+
+
+ + {/* Selected Day Details */} + {selectedDay && ( +
+
+
+

+ + {new Date(selectedDay.date).toLocaleDateString('es-ES', { + weekday: 'long', + day: 'numeric', + month: 'long' + })} + {selectedDay.isToday && ( + + Hoy + + )} +

+
+
+ + + Demanda Total: {selectedDay.demand} + +
+ + {getDemandLabel(selectedDay.demand)} + +
+
+
+ + {/* Product Breakdown */} + {selectedDay.products && ( +
+
Desglose por Producto:
+
+ {selectedDay.products.map((product, index) => ( +
+
+ {product.name} + + {product.confidence === 'high' ? 'Alta' : + product.confidence === 'medium' ? 'Media' : 'Baja'} + +
+
+ {product.demand} +
+
unidades
+
+ ))} +
+
+ )} +
+ )} + + {/* Weekly Summary */} + {currentWeek && ( +
+
Resumen Semanal:
+
+
+
+ {currentWeek.days.reduce((sum, day) => sum + day.demand, 0)} +
+
Total
+
+
+
+ {Math.round(currentWeek.days.reduce((sum, day) => sum + day.demand, 0) / 7)} +
+
Promedio/día
+
+
+
+ {Math.max(...currentWeek.days.map(d => d.demand))} +
+
Pico
+
+
+
+ {currentWeek.days.filter(d => d.isForecast).length} +
+
Predicciones
+
+
+
+ )} +
+ ); +}; + +export default DemandHeatmap; \ No newline at end of file diff --git a/frontend/src/components/ui/ProductionSchedule.tsx b/frontend/src/components/ui/ProductionSchedule.tsx new file mode 100644 index 00000000..b7def804 --- /dev/null +++ b/frontend/src/components/ui/ProductionSchedule.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { Clock, CheckCircle, AlertTriangle, TrendingUp } from 'lucide-react'; + +export interface ProductionItem { + id: string; + product: string; + quantity: number; + priority: 'high' | 'medium' | 'low'; + estimatedTime: number; // minutes + status: 'pending' | 'in_progress' | 'completed'; + confidence: number; // 0-1 + notes?: string; +} + +export interface ProductionTimeSlot { + time: string; + items: ProductionItem[]; + totalTime: number; +} + +interface ProductionScheduleProps { + schedule: ProductionTimeSlot[]; + onUpdateQuantity?: (itemId: string, newQuantity: number) => void; + onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void; + className?: string; +} + +const getPriorityConfig = (priority: ProductionItem['priority']) => { + switch (priority) { + case 'high': + return { + bgColor: 'bg-red-50', + borderColor: 'border-red-200', + textColor: 'text-red-800', + label: 'ALTA' + }; + case 'medium': + return { + bgColor: 'bg-yellow-50', + borderColor: 'border-yellow-200', + textColor: 'text-yellow-800', + label: 'MEDIA' + }; + default: + return { + bgColor: 'bg-green-50', + borderColor: 'border-green-200', + textColor: 'text-green-800', + label: 'BAJA' + }; + } +}; + +const getStatusIcon = (status: ProductionItem['status']) => { + switch (status) { + case 'completed': + return ; + case 'in_progress': + return ; + default: + return ; + } +}; + +const getConfidenceColor = (confidence: number) => { + if (confidence >= 0.8) return 'text-success-600'; + if (confidence >= 0.6) return 'text-warning-600'; + return 'text-danger-600'; +}; + +const ProductionItem: React.FC<{ + item: ProductionItem; + onUpdateQuantity?: (itemId: string, newQuantity: number) => void; + onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void; +}> = ({ item, onUpdateQuantity, onUpdateStatus }) => { + const priorityConfig = getPriorityConfig(item.priority); + + const handleQuantityChange = (delta: number) => { + const newQuantity = Math.max(0, item.quantity + delta); + onUpdateQuantity?.(item.id, newQuantity); + }; + + return ( +
+
+
+ {getStatusIcon(item.status)} + {item.product} + + {priorityConfig.label} + +
+
+ {Math.round(item.confidence * 100)}% confianza +
+
+ +
+
+
+ + {item.quantity} + +
+ unidades +
+ +
+ + {item.estimatedTime} min +
+
+ + {item.notes && ( +
+ {item.notes} +
+ )} + +
+ {item.status === 'pending' && ( + + )} + {item.status === 'in_progress' && ( + + )} + {item.status === 'completed' && ( +
+ ✓ Completado +
+ )} +
+
+ ); +}; + +const ProductionSchedule: React.FC = ({ + schedule, + onUpdateQuantity, + onUpdateStatus, + className = '' +}) => { + const getTotalItems = (items: ProductionItem[]) => { + return items.reduce((sum, item) => sum + item.quantity, 0); + }; + + return ( +
+
+

+ Plan de Producción de Hoy +

+
+ + Optimizado por IA +
+
+ + {schedule.map((timeSlot, index) => ( +
+
+
+ {timeSlot.time} +
+
+ {getTotalItems(timeSlot.items)} unidades • {timeSlot.totalTime} min total +
+
+ +
+ {timeSlot.items.map((item) => ( + + ))} +
+
+ ))} +
+ ); +}; + +export default ProductionSchedule; \ No newline at end of file diff --git a/frontend/src/components/ui/QuickActionsPanel.tsx b/frontend/src/components/ui/QuickActionsPanel.tsx new file mode 100644 index 00000000..6b285510 --- /dev/null +++ b/frontend/src/components/ui/QuickActionsPanel.tsx @@ -0,0 +1,373 @@ +import { useState } from 'react'; +import { + Zap, Plus, Minus, RotateCcw, TrendingUp, ShoppingCart, + Clock, AlertTriangle, Phone, MessageSquare, Calculator, + RefreshCw, Package, Users, Settings, ChevronRight +} from 'lucide-react'; + +interface QuickAction { + id: string; + title: string; + description: string; + icon: any; + category: 'production' | 'inventory' | 'sales' | 'customer' | 'system'; + shortcut?: string; + requiresConfirmation?: boolean; + estimatedTime?: string; + badge?: { + text: string; + color: 'red' | 'yellow' | 'green' | 'blue' | 'purple'; + }; +} + +interface QuickActionsPanelProps { + onActionClick?: (actionId: string) => void; + availableActions?: QuickAction[]; + compactMode?: boolean; + showCategories?: boolean; + className?: string; +} + +const QuickActionsPanel: React.FC = ({ + onActionClick, + availableActions, + compactMode = false, + showCategories = true, + className = '' +}) => { + const [selectedCategory, setSelectedCategory] = useState('all'); + const [actionInProgress, setActionInProgress] = useState(null); + + const defaultActions: QuickAction[] = [ + // Production Actions + { + id: 'increase_production', + title: 'Aumentar Producción', + description: 'Incrementa rápidamente la producción del producto más demandado', + icon: Plus, + category: 'production', + shortcut: 'P + A', + estimatedTime: '2 min', + badge: { text: 'Croissants', color: 'green' } + }, + { + id: 'emergency_batch', + title: 'Lote de Emergencia', + description: 'Inicia producción urgente para productos con stock bajo', + icon: AlertTriangle, + category: 'production', + requiresConfirmation: true, + estimatedTime: '45 min', + badge: { text: '3 productos', color: 'red' } + }, + { + id: 'adjust_schedule', + title: 'Ajustar Horario', + description: 'Modifica el horario de producción basado en predicciones', + icon: Clock, + category: 'production', + estimatedTime: '1 min' + }, + + // Inventory Actions + { + id: 'check_stock', + title: 'Revisar Stock', + description: 'Verifica niveles de inventario y productos próximos a agotarse', + icon: Package, + category: 'inventory', + shortcut: 'I + S', + badge: { text: '2 bajos', color: 'yellow' } + }, + { + id: 'order_supplies', + title: 'Pedir Suministros', + description: 'Genera orden automática de ingredientes basada en predicciones', + icon: ShoppingCart, + category: 'inventory', + estimatedTime: '3 min', + badge: { text: 'Harina, Huevos', color: 'blue' } + }, + { + id: 'waste_report', + title: 'Reportar Desperdicio', + description: 'Registra productos no vendidos para mejorar predicciones', + icon: Minus, + category: 'inventory', + estimatedTime: '2 min' + }, + + // Sales Actions + { + id: 'price_adjustment', + title: 'Ajustar Precios', + description: 'Modifica precios para productos con baja rotación', + icon: Calculator, + category: 'sales', + requiresConfirmation: true, + badge: { text: 'Magdalenas -10%', color: 'yellow' } + }, + { + id: 'promotion_activate', + title: 'Activar Promoción', + description: 'Inicia promoción instantánea para productos específicos', + icon: TrendingUp, + category: 'sales', + estimatedTime: '1 min', + badge: { text: '2x1 Tartas', color: 'green' } + }, + + // Customer Actions + { + id: 'notify_customers', + title: 'Avisar Clientes', + description: 'Notifica a clientes regulares sobre disponibilidad especial', + icon: MessageSquare, + category: 'customer', + badge: { text: '15 clientes', color: 'blue' } + }, + { + id: 'call_supplier', + title: 'Llamar Proveedor', + description: 'Contacto rápido con proveedor principal', + icon: Phone, + category: 'customer', + estimatedTime: '5 min' + }, + + // System Actions + { + id: 'refresh_predictions', + title: 'Actualizar Predicciones', + description: 'Recalcula predicciones con datos más recientes', + icon: RefreshCw, + category: 'system', + estimatedTime: '30 seg' + }, + { + id: 'backup_data', + title: 'Respaldar Datos', + description: 'Crea respaldo de la información del día', + icon: RotateCcw, + category: 'system', + estimatedTime: '1 min' + } + ]; + + const actions = availableActions || defaultActions; + + const categories = [ + { id: 'all', name: 'Todas', icon: Zap }, + { id: 'production', name: 'Producción', icon: Package }, + { id: 'inventory', name: 'Inventario', icon: ShoppingCart }, + { id: 'sales', name: 'Ventas', icon: TrendingUp }, + { id: 'customer', name: 'Clientes', icon: Users }, + { id: 'system', name: 'Sistema', icon: Settings } + ]; + + const filteredActions = actions.filter(action => + selectedCategory === 'all' || action.category === selectedCategory + ); + + const handleActionClick = async (action: QuickAction) => { + if (action.requiresConfirmation) { + const confirmed = window.confirm(`¿Estás seguro de que quieres ejecutar "${action.title}"?`); + if (!confirmed) return; + } + + setActionInProgress(action.id); + + // Simulate action execution + await new Promise(resolve => setTimeout(resolve, 1000)); + + setActionInProgress(null); + onActionClick?.(action.id); + }; + + const getBadgeColors = (color: string) => { + const colors = { + red: 'bg-red-100 text-red-800', + yellow: 'bg-yellow-100 text-yellow-800', + green: 'bg-green-100 text-green-800', + blue: 'bg-blue-100 text-blue-800', + purple: 'bg-purple-100 text-purple-800' + }; + return colors[color as keyof typeof colors] || colors.blue; + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+

+ Acciones Rápidas +

+

+ Tareas comunes del día a día +

+
+
+ +
+
+ {filteredActions.length} acciones disponibles +
+
+ Usa atajos de teclado para mayor velocidad +
+
+
+ + {/* Category Filters */} + {showCategories && !compactMode && ( +
+ {categories.map(category => { + const IconComponent = category.icon; + const isSelected = selectedCategory === category.id; + return ( + + ); + })} +
+ )} +
+ + {/* Actions Grid */} +
+
+ {filteredActions.map(action => { + const IconComponent = action.icon; + const isInProgress = actionInProgress === action.id; + + return ( + + ); + })} +
+ + {filteredActions.length === 0 && ( +
+ +

No hay acciones disponibles

+

+ Las acciones aparecerán basadas en el estado actual de tu panadería +

+
+ )} +
+ + {/* Keyboard Shortcuts Help */} + {!compactMode && ( +
+
+
+ 💡 Tip: + Usa Ctrl + K para búsqueda rápida de acciones +
+
+
+ )} +
+ ); +}; + +export default QuickActionsPanel; \ No newline at end of file diff --git a/frontend/src/components/ui/RevenueMetrics.tsx b/frontend/src/components/ui/RevenueMetrics.tsx new file mode 100644 index 00000000..c4144d81 --- /dev/null +++ b/frontend/src/components/ui/RevenueMetrics.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Euro, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react'; + +export interface RevenueData { + projectedDailyRevenue: number; + lostRevenueFromStockouts: number; + wasteCost: number; + revenueTrend: 'up' | 'down' | 'stable'; + trendPercentage: number; + currency: string; +} + +interface RevenueMetricsProps { + revenueData: RevenueData; + className?: string; +} + +const formatCurrency = (amount: number, currency: string = '€') => { + return `${amount.toFixed(0)}${currency}`; +}; + +const RevenueMetrics: React.FC = ({ revenueData, className = '' }) => { + const getTrendIcon = () => { + switch (revenueData.revenueTrend) { + case 'up': + return ; + case 'down': + return ; + default: + return ; + } + }; + + const getTrendColor = () => { + switch (revenueData.revenueTrend) { + case 'up': + return 'text-success-600'; + case 'down': + return 'text-danger-600'; + default: + return 'text-gray-600'; + } + }; + + return ( +
+ {/* Projected Daily Revenue */} +
+
+
+
+
+ +
+
+

Ingresos Previstos Hoy

+

+ {formatCurrency(revenueData.projectedDailyRevenue, revenueData.currency)} +

+
+
+
+ {getTrendIcon()} + + {revenueData.trendPercentage > 0 ? '+' : ''}{revenueData.trendPercentage}% vs ayer + +
+
+
+
+ + {/* Lost Revenue from Stockouts */} +
+
+
+ +
+
+

Ventas Perdidas

+

+ -{formatCurrency(revenueData.lostRevenueFromStockouts, revenueData.currency)} +

+

Por falta de stock (últimos 7 días)

+
+
+
+ + {/* Waste Cost Tracker */} +
+
+
+ +
+
+

Coste Desperdicio

+

+ -{formatCurrency(revenueData.wasteCost, revenueData.currency)} +

+

Productos no vendidos (esta semana)

+
+
+
+
+ ); +}; + +export default RevenueMetrics; \ No newline at end of file diff --git a/frontend/src/components/ui/WhatIfPlanner.tsx b/frontend/src/components/ui/WhatIfPlanner.tsx new file mode 100644 index 00000000..806fb8b1 --- /dev/null +++ b/frontend/src/components/ui/WhatIfPlanner.tsx @@ -0,0 +1,557 @@ +import { useState } from 'react'; +import { Play, RotateCcw, TrendingUp, TrendingDown, AlertTriangle, Euro, Calendar } from 'lucide-react'; + +interface Scenario { + id: string; + name: string; + description: string; + type: 'weather' | 'promotion' | 'event' | 'supply' | 'custom'; + icon: any; + parameters: { + [key: string]: { + label: string; + type: 'number' | 'select' | 'boolean'; + value: any; + options?: string[]; + min?: number; + max?: number; + step?: number; + unit?: string; + }; + }; +} + +interface ScenarioResult { + scenarioId: string; + demandChange: number; + revenueImpact: number; + productImpacts: Array<{ + name: string; + demandChange: number; + newDemand: number; + revenueImpact: number; + }>; + recommendations: string[]; + confidence: 'high' | 'medium' | 'low'; +} + +interface WhatIfPlannerProps { + baselineData?: { + totalDemand: number; + totalRevenue: number; + products: Array<{ + name: string; + demand: number; + price: number; + }>; + }; + onScenarioRun?: (scenario: Scenario, result: ScenarioResult) => void; + className?: string; +} + +const WhatIfPlanner: React.FC = ({ + baselineData = { + totalDemand: 180, + totalRevenue: 420, + products: [ + { name: 'Croissants', demand: 45, price: 2.5 }, + { name: 'Pan', demand: 30, price: 1.8 }, + { name: 'Magdalenas', demand: 25, price: 1.2 }, + { name: 'Empanadas', demand: 20, price: 3.2 }, + { name: 'Tartas', demand: 15, price: 12.0 }, + ] + }, + onScenarioRun, + className = '' +}) => { + const [selectedScenario, setSelectedScenario] = useState(null); + const [scenarioResult, setScenarioResult] = useState(null); + const [isRunning, setIsRunning] = useState(false); + + const scenarios: Scenario[] = [ + { + id: 'rain', + name: 'Día Lluvioso', + description: 'Simula el impacto de un día de lluvia en Madrid', + type: 'weather', + icon: AlertTriangle, + parameters: { + rainIntensity: { + label: 'Intensidad de lluvia', + type: 'select', + value: 'moderate', + options: ['light', 'moderate', 'heavy'] + }, + temperature: { + label: 'Temperatura (°C)', + type: 'number', + value: 15, + min: 5, + max: 25, + step: 1, + unit: '°C' + } + } + }, + { + id: 'promotion', + name: 'Promoción Especial', + description: 'Aplica un descuento y ve el impacto en la demanda', + type: 'promotion', + icon: TrendingUp, + parameters: { + discount: { + label: 'Descuento', + type: 'number', + value: 20, + min: 5, + max: 50, + step: 5, + unit: '%' + }, + targetProduct: { + label: 'Producto objetivo', + type: 'select', + value: 'Croissants', + options: baselineData.products.map(p => p.name) + }, + duration: { + label: 'Duración (días)', + type: 'number', + value: 3, + min: 1, + max: 7, + step: 1, + unit: 'días' + } + } + }, + { + id: 'weekend', + name: 'Fin de Semana', + description: 'Simula la demanda típica de fin de semana', + type: 'event', + icon: Calendar, + parameters: { + dayType: { + label: 'Tipo de día', + type: 'select', + value: 'saturday', + options: ['saturday', 'sunday', 'holiday'] + }, + weatherGood: { + label: 'Buen tiempo', + type: 'boolean', + value: true + } + } + }, + { + id: 'supply_shortage', + name: 'Escasez de Ingredientes', + description: 'Simula falta de ingredientes clave', + type: 'supply', + icon: AlertTriangle, + parameters: { + ingredient: { + label: 'Ingrediente afectado', + type: 'select', + value: 'flour', + options: ['flour', 'butter', 'eggs', 'sugar', 'chocolate'] + }, + shortage: { + label: 'Nivel de escasez', + type: 'select', + value: 'moderate', + options: ['mild', 'moderate', 'severe'] + } + } + } + ]; + + const runScenario = async (scenario: Scenario) => { + setIsRunning(true); + setScenarioResult(null); + + // Simulate API call delay + await new Promise(resolve => setTimeout(resolve, 1500)); + + // Generate realistic scenario results based on parameters + const result = generateScenarioResult(scenario, baselineData); + + setScenarioResult(result); + setIsRunning(false); + onScenarioRun?.(scenario, result); + }; + + const generateScenarioResult = (scenario: Scenario, baseline: typeof baselineData): ScenarioResult => { + let demandChange = 0; + let productImpacts: ScenarioResult['productImpacts'] = []; + let recommendations: string[] = []; + let confidence: 'high' | 'medium' | 'low' = 'medium'; + + switch (scenario.id) { + case 'rain': + const intensity = scenario.parameters.rainIntensity.value; + demandChange = intensity === 'light' ? -5 : intensity === 'moderate' ? -15 : -25; + confidence = 'high'; + recommendations = [ + 'Reduce la producción de productos para llevar', + 'Aumenta café caliente y productos de temporada', + 'Prepara promociones para el día siguiente' + ]; + productImpacts = baseline.products.map(product => { + const change = product.name === 'Croissants' ? demandChange * 1.2 : demandChange; + return { + name: product.name, + demandChange: Math.round(change * product.demand / 100), + newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)), + revenueImpact: Math.round(change * product.demand * product.price / 100) + }; + }); + break; + + case 'promotion': + const discount = scenario.parameters.discount.value; + const targetProduct = scenario.parameters.targetProduct.value; + demandChange = discount * 1.5; // 20% discount = ~30% demand increase + confidence = 'high'; + recommendations = [ + `Aumenta la producción de ${targetProduct} en un ${Math.round(demandChange)}%`, + 'Asegúrate de tener suficientes ingredientes', + 'Promociona productos complementarios' + ]; + productImpacts = baseline.products.map(product => { + const change = product.name === targetProduct ? demandChange : demandChange * 0.3; + return { + name: product.name, + demandChange: Math.round(change * product.demand / 100), + newDemand: product.demand + Math.round(change * product.demand / 100), + revenueImpact: Math.round((change * product.demand / 100) * product.price * (product.name === targetProduct ? (1 - discount/100) : 1)) + }; + }); + break; + + case 'weekend': + const isWeekend = scenario.parameters.dayType.value; + const goodWeather = scenario.parameters.weatherGood.value; + demandChange = (isWeekend === 'saturday' ? 25 : isWeekend === 'sunday' ? 15 : 35) + (goodWeather ? 10 : -5); + confidence = 'high'; + recommendations = [ + 'Aumenta la producción de productos especiales', + 'Prepara más variedad para familias', + 'Considera abrir más temprano' + ]; + productImpacts = baseline.products.map(product => { + const multiplier = product.name === 'Tartas' ? 1.5 : product.name === 'Croissants' ? 1.3 : 1.0; + const change = demandChange * multiplier; + return { + name: product.name, + demandChange: Math.round(change * product.demand / 100), + newDemand: product.demand + Math.round(change * product.demand / 100), + revenueImpact: Math.round(change * product.demand * product.price / 100) + }; + }); + break; + + case 'supply_shortage': + const ingredient = scenario.parameters.ingredient.value; + const shortage = scenario.parameters.shortage.value; + demandChange = shortage === 'mild' ? -10 : shortage === 'moderate' ? -25 : -40; + confidence = 'medium'; + recommendations = [ + 'Busca proveedores alternativos inmediatamente', + 'Promociona productos que no requieren este ingrediente', + 'Informa a los clientes sobre productos no disponibles' + ]; + const affectedProducts = getAffectedProducts(ingredient); + productImpacts = baseline.products.map(product => { + const change = affectedProducts.includes(product.name) ? demandChange : 0; + return { + name: product.name, + demandChange: Math.round(change * product.demand / 100), + newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)), + revenueImpact: Math.round(change * product.demand * product.price / 100) + }; + }); + break; + } + + const totalRevenueImpact = productImpacts.reduce((sum, impact) => sum + impact.revenueImpact, 0); + + return { + scenarioId: scenario.id, + demandChange, + revenueImpact: totalRevenueImpact, + productImpacts, + recommendations, + confidence + }; + }; + + const getAffectedProducts = (ingredient: string): string[] => { + const ingredientMap: Record = { + flour: ['Pan', 'Croissants', 'Magdalenas', 'Tartas'], + butter: ['Croissants', 'Tartas', 'Magdalenas'], + eggs: ['Magdalenas', 'Tartas'], + sugar: ['Magdalenas', 'Tartas'], + chocolate: ['Tartas'] + }; + return ingredientMap[ingredient] || []; + }; + + const updateParameter = (scenarioId: string, paramKey: string, value: any) => { + // This would update the scenario parameters in a real implementation + console.log('Updating parameter:', scenarioId, paramKey, value); + }; + + const resetScenario = () => { + setSelectedScenario(null); + setScenarioResult(null); + }; + + const selectedScenarioData = scenarios.find(s => s.id === selectedScenario); + + return ( +
+
+
+

+ + Simulador de Escenarios +

+

+ Simula diferentes situaciones y ve el impacto en tu negocio +

+
+ {selectedScenario && ( + + )} +
+ + {!selectedScenario ? ( + // Scenario Selection +
+ {scenarios.map(scenario => { + const IconComponent = scenario.icon; + return ( + + ); + })} +
+ ) : ( + // Selected Scenario Configuration +
+ {selectedScenarioData && ( + <> + {/* Scenario Header */} +
+ +
+

{selectedScenarioData.name}

+

{selectedScenarioData.description}

+
+
+ + {/* Parameters */} +
+
Parámetros del Escenario:
+
+ {Object.entries(selectedScenarioData.parameters).map(([key, param]) => ( +
+ + {param.type === 'select' ? ( + + ) : param.type === 'number' ? ( +
+ updateParameter(selectedScenarioData.id, key, Number(e.target.value))} + className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> + {param.unit && ( + {param.unit} + )} +
+ ) : param.type === 'boolean' ? ( + + ) : null} +
+ ))} +
+
+ + {/* Run Scenario Button */} + + + {/* Results */} + {scenarioResult && ( +
+
+
Resultados de la Simulación
+ + {/* Overall Impact */} +
+
+
+ Cambio en Demanda + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {scenarioResult.demandChange >= 0 ? '+' : ''}{scenarioResult.demandChange}% + +
+
+ +
+
+ Impacto en Ingresos + = 0 ? 'text-green-600' : 'text-red-600'}`}> + + {scenarioResult.revenueImpact >= 0 ? '+' : ''}{scenarioResult.revenueImpact} + +
+
+ +
+
+ Confianza + + {scenarioResult.confidence === 'high' ? 'Alta' : + scenarioResult.confidence === 'medium' ? 'Media' : 'Baja'} + +
+
+
+ + {/* Product Impact */} +
+
Impacto por Producto:
+
+ {scenarioResult.productImpacts.map((impact, index) => ( +
+
+ {impact.name} + + {baselineData.products.find(p => p.name === impact.name)?.demand} → {impact.newDemand} + +
+
+ = 0 ? 'text-green-600' : 'text-red-600'}`}> + {impact.demandChange >= 0 ? '+' : ''}{impact.demandChange} + + = 0 ? 'text-green-600' : 'text-red-600'}`}> + + {impact.revenueImpact >= 0 ? '+' : ''}{impact.revenueImpact} + +
+
+ ))} +
+
+ + {/* Recommendations */} +
+
Recomendaciones:
+
    + {scenarioResult.recommendations.map((rec, index) => ( +
  • +
    + {rec} +
  • + ))} +
+
+
+
+ )} + + )} +
+ )} +
+ ); +}; + +export default WhatIfPlanner; \ No newline at end of file diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index 5f2b82e1..7c7899ea 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -229,14 +229,16 @@ export const useDashboard = () => { // Load data on mount and when tenant changes useEffect(() => { - loadDashboardData(); - }, [loadDashboardData]); + if (tenantId) { + loadDashboardData(tenantId); + } + }, [loadDashboardData, tenantId]); return { ...dashboardData, isLoading: isLoading || dataLoading || forecastLoading, error: error || dataError || forecastError, - reload: loadDashboardData, + reload: () => tenantId ? loadDashboardData(tenantId) : Promise.resolve(), clearError: () => setError(null) }; }; diff --git a/frontend/src/hooks/useOrderSuggestions.ts b/frontend/src/hooks/useOrderSuggestions.ts new file mode 100644 index 00000000..05847b32 --- /dev/null +++ b/frontend/src/hooks/useOrderSuggestions.ts @@ -0,0 +1,286 @@ +// Real API hook for Order Suggestions using backend data +import { useState, useCallback, useEffect } from 'react'; +import { useData, useForecast } from '../api'; +import { useTenantId } from './useTenantId'; +import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions'; + +// Product price mapping that could come from backend +const PRODUCT_PRICES: Record = { + 'Pan de Molde': 1.80, + 'Baguettes': 2.80, + 'Croissants': 2.50, + 'Magdalenas': 2.40, + 'Café en Grano': 17.50, // per kg + 'Leche Entera': 0.95, // per liter + 'Mantequilla': 4.20, // per kg + 'Vasos de Café': 0.08, // per unit + 'Servilletas': 0.125, // per pack + 'Bolsas papel': 0.12, // per unit +}; + +// Suppliers mapping +const SUPPLIERS: Record = { + 'Pan de Molde': 'Panadería Central Madrid', + 'Baguettes': 'Panadería Central Madrid', + 'Croissants': 'Panadería Central Madrid', + 'Magdalenas': 'Panadería Central Madrid', + 'Café en Grano': 'Cafés Premium', + 'Leche Entera': 'Lácteos Frescos SA', + 'Mantequilla': 'Lácteos Frescos SA', + 'Vasos de Café': 'Suministros Hostelería', + 'Servilletas': 'Suministros Hostelería', + 'Bolsas papel': 'Distribuciones Madrid', +}; + +export const useOrderSuggestions = () => { + const [dailyOrders, setDailyOrders] = useState([]); + const [weeklyOrders, setWeeklyOrders] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { tenantId } = useTenantId(); + const { + getProductsList, + getSalesAnalytics, + getDashboardStats, + getCurrentWeather + } = useData(); + const { + createSingleForecast, + getQuickForecasts, + getForecastAlerts + } = useForecast(); + + // Generate daily order suggestions based on real forecast data + const generateDailyOrderSuggestions = useCallback(async (): Promise => { + if (!tenantId) return []; + + try { + // Get products list from backend + const products = await getProductsList(tenantId); + const dailyProducts = products.filter(p => + ['Pan de Molde', 'Baguettes', 'Croissants', 'Magdalenas'].includes(p) + ); + + // Get quick forecasts for these products + const quickForecasts = await getQuickForecasts(tenantId); + + // Get weather data to determine urgency + const weather = await getCurrentWeather(tenantId, 40.4168, -3.7038); + + const suggestions: DailyOrderItem[] = []; + + for (const product of dailyProducts) { + // Find forecast for this product + const forecast = quickForecasts.find(f => f.product_name === product); + + if (forecast) { + // Calculate suggested quantity based on prediction + const suggestedQuantity = Math.max(forecast.next_day_prediction, 10); + + // Determine urgency based on confidence and trend + let urgency: 'high' | 'medium' | 'low' = 'medium'; + if (forecast.confidence_score > 0.9 && forecast.trend_direction === 'up') { + urgency = 'high'; + } else if (forecast.confidence_score < 0.7) { + urgency = 'low'; + } + + // Generate reason based on forecast data + let reason = `Predicción: ${forecast.next_day_prediction} unidades`; + if (forecast.trend_direction === 'up') { + reason += ' (tendencia al alza)'; + } + if (weather && weather.precipitation > 0) { + reason += ', lluvia prevista'; + urgency = urgency === 'low' ? 'medium' : 'high'; + } + + const orderItem: DailyOrderItem = { + id: `daily-${product.toLowerCase().replace(/\s+/g, '-')}`, + product, + emoji: getProductEmoji(product), + suggestedQuantity: Math.round(suggestedQuantity), + currentQuantity: Math.round(suggestedQuantity * 0.2), // Assume 20% current stock + unit: 'unidades', + urgency, + reason, + confidence: Math.round(forecast.confidence_score * 100), + supplier: SUPPLIERS[product] || 'Proveedor General', + estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 2.5) * 100) / 100, + lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0] + }; + + suggestions.push(orderItem); + } + } + + return suggestions; + } catch (error) { + console.error('Error generating daily order suggestions:', error); + return []; + } + }, [tenantId, getProductsList, getQuickForecasts, getCurrentWeather]); + + // Generate weekly order suggestions based on sales analytics + const generateWeeklyOrderSuggestions = useCallback(async (): Promise => { + if (!tenantId) return []; + + try { + // Get sales analytics for the past month + const endDate = new Date().toISOString().split('T')[0]; + const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const analytics = await getSalesAnalytics(tenantId, startDate, endDate); + + // Weekly products (ingredients and supplies) + const weeklyProducts = [ + 'Café en Grano', + 'Leche Entera', + 'Mantequilla', + 'Vasos de Café', + 'Servilletas', + 'Bolsas papel' + ]; + + const suggestions: WeeklyOrderItem[] = []; + + for (const product of weeklyProducts) { + // Calculate weekly consumption based on analytics + const weeklyConsumption = calculateWeeklyConsumption(product, analytics); + const currentStock = Math.round(weeklyConsumption * (0.3 + Math.random() * 0.4)); // Random stock between 30-70% of weekly need + const stockDays = Math.max(1, Math.round((currentStock / weeklyConsumption) * 7)); + + // Determine frequency + const frequency: 'weekly' | 'biweekly' = + ['Café en Grano', 'Leche Entera', 'Mantequilla'].includes(product) ? 'weekly' : 'biweekly'; + + const suggestedQuantity = frequency === 'weekly' ? + Math.round(weeklyConsumption * 1.1) : // 10% buffer for weekly + Math.round(weeklyConsumption * 2.2); // 2 weeks + 10% buffer + + const orderItem: WeeklyOrderItem = { + id: `weekly-${product.toLowerCase().replace(/\s+/g, '-')}`, + product, + emoji: getProductEmoji(product), + suggestedQuantity, + currentStock, + unit: getProductUnit(product), + frequency, + nextOrderDate: getNextOrderDate(frequency, stockDays), + supplier: SUPPLIERS[product] || 'Proveedor General', + estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 1.0) * 100) / 100, + stockDays, + confidence: stockDays <= 2 ? 95 : stockDays <= 5 ? 85 : 75 + }; + + suggestions.push(orderItem); + } + + return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency + } catch (error) { + console.error('Error generating weekly order suggestions:', error); + return []; + } + }, [tenantId, getSalesAnalytics]); + + // Load order suggestions + const loadOrderSuggestions = useCallback(async () => { + if (!tenantId) return; + + setIsLoading(true); + setError(null); + + try { + const [daily, weekly] = await Promise.all([ + generateDailyOrderSuggestions(), + generateWeeklyOrderSuggestions() + ]); + + setDailyOrders(daily); + setWeeklyOrders(weekly); + } catch (error) { + console.error('Error loading order suggestions:', error); + setError(error instanceof Error ? error.message : 'Failed to load order suggestions'); + } finally { + setIsLoading(false); + } + }, [tenantId, generateDailyOrderSuggestions, generateWeeklyOrderSuggestions]); + + // Load on mount and when tenant changes + useEffect(() => { + loadOrderSuggestions(); + }, [loadOrderSuggestions]); + + return { + dailyOrders, + weeklyOrders, + isLoading, + error, + reload: loadOrderSuggestions, + clearError: () => setError(null), + }; +}; + +// Helper functions +function getProductEmoji(product: string): string { + const emojiMap: Record = { + 'Pan de Molde': '🍞', + 'Baguettes': '🥖', + 'Croissants': '🥐', + 'Magdalenas': '🧁', + 'Café en Grano': '☕', + 'Leche Entera': '🥛', + 'Mantequilla': '🧈', + 'Vasos de Café': '🥤', + 'Servilletas': '🧻', + 'Bolsas papel': '🛍️' + }; + return emojiMap[product] || '📦'; +} + +function getProductUnit(product: string): string { + const unitMap: Record = { + 'Pan de Molde': 'unidades', + 'Baguettes': 'unidades', + 'Croissants': 'unidades', + 'Magdalenas': 'unidades', + 'Café en Grano': 'kg', + 'Leche Entera': 'litros', + 'Mantequilla': 'kg', + 'Vasos de Café': 'unidades', + 'Servilletas': 'paquetes', + 'Bolsas papel': 'unidades' + }; + return unitMap[product] || 'unidades'; +} + +function calculateWeeklyConsumption(product: string, analytics: any): number { + // This would ideally come from sales analytics + // For now, use realistic estimates based on product type + const weeklyEstimates: Record = { + 'Café en Grano': 5, // 5kg per week + 'Leche Entera': 25, // 25L per week + 'Mantequilla': 3, // 3kg per week + 'Vasos de Café': 500, // 500 cups per week + 'Servilletas': 10, // 10 packs per week + 'Bolsas papel': 200 // 200 bags per week + }; + + return weeklyEstimates[product] || 10; +} + +function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string { + const today = new Date(); + let daysToAdd = frequency === 'weekly' ? 7 : 14; + + // If stock is low, suggest ordering sooner + if (stockDays <= 2) { + daysToAdd = 1; // Order tomorrow + } else if (stockDays <= 5) { + daysToAdd = Math.min(daysToAdd, 3); // Order within 3 days + } + + const nextDate = new Date(today.getTime() + daysToAdd * 24 * 60 * 60 * 1000); + return nextDate.toISOString().split('T')[0]; +} \ No newline at end of file diff --git a/frontend/src/hooks/useRealAlerts.ts b/frontend/src/hooks/useRealAlerts.ts new file mode 100644 index 00000000..b5dd06bf --- /dev/null +++ b/frontend/src/hooks/useRealAlerts.ts @@ -0,0 +1,162 @@ +// Real API hook for Critical Alerts using backend forecast alerts +import { useState, useCallback, useEffect } from 'react'; +import { useForecast } from '../api'; +import { useTenantId } from './useTenantId'; + +export interface RealAlert { + id: string; + type: 'stock' | 'weather' | 'order' | 'production' | 'system'; + severity: 'high' | 'medium' | 'low'; + title: string; + description: string; + action?: string; + time: string; +} + +export const useRealAlerts = () => { + const [alerts, setAlerts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { tenantId } = useTenantId(); + const { getForecastAlerts, acknowledgeForecastAlert } = useForecast(); + + // Transform backend forecast alerts to frontend alert format + const transformForecastAlert = (alert: any): RealAlert => { + // Map alert types + let type: RealAlert['type'] = 'system'; + if (alert.alert_type?.includes('stock') || alert.alert_type?.includes('demand')) { + type = 'stock'; + } else if (alert.alert_type?.includes('weather')) { + type = 'weather'; + } else if (alert.alert_type?.includes('production')) { + type = 'production'; + } + + // Map severity + let severity: RealAlert['severity'] = 'medium'; + if (alert.severity === 'critical' || alert.severity === 'high') { + severity = 'high'; + } else if (alert.severity === 'low') { + severity = 'low'; + } + + // Generate user-friendly title and description + let title = alert.message; + let description = alert.message; + + if (alert.alert_type?.includes('high_demand')) { + title = 'Alta Demanda Prevista'; + description = `Se prevé alta demanda. ${alert.message}`; + } else if (alert.alert_type?.includes('low_confidence')) { + title = 'Predicción Incierta'; + description = `Baja confianza en predicción. ${alert.message}`; + } else if (alert.alert_type?.includes('stock_risk')) { + title = 'Riesgo de Desabastecimiento'; + description = `Posible falta de stock. ${alert.message}`; + } + + return { + id: alert.id, + type, + severity, + title, + description, + action: 'Ver detalles', + time: new Date(alert.created_at).toLocaleTimeString('es-ES', { + hour: '2-digit', + minute: '2-digit' + }) + }; + }; + + // Load real alerts from backend + const loadAlerts = useCallback(async () => { + if (!tenantId) return; + + setIsLoading(true); + setError(null); + + try { + // Get forecast alerts from backend + const forecastAlerts = await getForecastAlerts(tenantId); + + // Filter only active alerts + const activeAlerts = forecastAlerts.filter(alert => alert.is_active); + + // Transform to frontend format + const transformedAlerts = activeAlerts.map(transformForecastAlert); + + // Sort by severity and time (most recent first) + transformedAlerts.sort((a, b) => { + // First by severity (high > medium > low) + const severityOrder = { high: 3, medium: 2, low: 1 }; + const severityDiff = severityOrder[b.severity] - severityOrder[a.severity]; + if (severityDiff !== 0) return severityDiff; + + // Then by time (most recent first) + return b.time.localeCompare(a.time); + }); + + setAlerts(transformedAlerts.slice(0, 3)); // Show max 3 alerts in dashboard + } catch (error) { + console.error('Error loading alerts:', error); + setError(error instanceof Error ? error.message : 'Failed to load alerts'); + + // Fallback to sample alerts based on common scenarios + setAlerts(generateFallbackAlerts()); + } finally { + setIsLoading(false); + } + }, [tenantId, getForecastAlerts]); + + // Handle alert acknowledgment + const handleAlertAction = useCallback(async (alertId: string) => { + if (!tenantId) return; + + try { + await acknowledgeForecastAlert(tenantId, alertId); + // Remove acknowledged alert from local state + setAlerts(prev => prev.filter(alert => alert.id !== alertId)); + } catch (error) { + console.error('Error acknowledging alert:', error); + } + }, [tenantId, acknowledgeForecastAlert]); + + // Generate fallback alerts when API fails + const generateFallbackAlerts = (): RealAlert[] => { + const now = new Date(); + const timeString = now.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); + + return [ + { + id: 'fallback-1', + type: 'stock', + severity: 'high', + title: 'Stock Bajo de Croissants', + description: 'Se prevé alta demanda este fin de semana', + action: 'Aumentar producción', + time: timeString + } + ]; + }; + + // Load alerts on mount and when tenant changes + useEffect(() => { + loadAlerts(); + + // Refresh alerts every 5 minutes + const interval = setInterval(loadAlerts, 5 * 60 * 1000); + + return () => clearInterval(interval); + }, [loadAlerts]); + + return { + alerts, + isLoading, + error, + onAlertAction: handleAlertAction, + reload: loadAlerts, + clearError: () => setError(null), + }; +}; \ No newline at end of file diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index cdfc7440..6529210d 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -1,360 +1,242 @@ -import React, { useState, useEffect } from 'react'; -import { TrendingUp, TrendingDown, Package, AlertTriangle, Cloud, Users } from 'lucide-react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts'; - +import React from 'react'; import { useDashboard } from '../../hooks/useDashboard'; +import { useOrderSuggestions } from '../../hooks/useOrderSuggestions'; +import { useRealAlerts } from '../../hooks/useRealAlerts'; +// Import simplified components +import TodayRevenue from '../../components/simple/TodayRevenue'; +import CriticalAlerts from '../../components/simple/CriticalAlerts'; +import TodayProduction from '../../components/simple/TodayProduction'; +import QuickActions from '../../components/simple/QuickActions'; +import QuickOverview from '../../components/simple/QuickOverview'; +import OrderSuggestions from '../../components/simple/OrderSuggestions'; -// Helper functions -const getConfidenceColor = (confidence: 'high' | 'medium' | 'low') => { - switch (confidence) { - case 'high': - return 'bg-success-100 text-success-800'; - case 'medium': - return 'bg-warning-100 text-warning-800'; - case 'low': - return 'bg-danger-100 text-danger-800'; - default: - return 'bg-gray-100 text-gray-800'; - } -}; +interface DashboardPageProps { + onNavigateToOrders?: () => void; + onNavigateToReports?: () => void; + onNavigateToProduction?: () => void; +} -const getConfidenceLabel = (confidence: 'high' | 'medium' | 'low') => { - switch (confidence) { - case 'high': - return 'Alta'; - case 'medium': - return 'Media'; - case 'low': - return 'Baja'; - default: - return 'Media'; - } -}; - -const DashboardPage = () => { +const DashboardPage: React.FC = ({ + onNavigateToOrders, + onNavigateToReports, + onNavigateToProduction +}) => { const { weather, - todayForecasts, - metrics, - products, isLoading, error, - reload + reload, + todayForecasts, + metrics } = useDashboard(); - if (isLoading) { - return
Loading dashboard...
; - } + // Use real API data for order suggestions + const { + dailyOrders: realDailyOrders, + weeklyOrders: realWeeklyOrders, + isLoading: ordersLoading + } = useOrderSuggestions(); - if (error) { + // Use real API data for alerts + const { + alerts: realAlerts, + onAlertAction + } = useRealAlerts(); + + // Transform forecast data for production component + + const mockProduction = todayForecasts.map((forecast, index) => ({ + id: `prod-${index}`, + product: forecast.product, + emoji: forecast.product.toLowerCase().includes('croissant') ? '🥐' : + forecast.product.toLowerCase().includes('pan') ? '🍞' : + forecast.product.toLowerCase().includes('magdalena') ? '🧁' : '🥖', + quantity: forecast.predicted, + status: 'pending' as const, + scheduledTime: index < 3 ? '06:00' : '14:00', + confidence: forecast.confidence === 'high' ? 0.9 : + forecast.confidence === 'medium' ? 0.7 : 0.5 + })); + + + // Helper function for greeting + const getGreeting = () => { + const hour = new Date().getHours(); + if (hour < 12) return 'Buenos días'; + if (hour < 18) return 'Buenas tardes'; + return 'Buenas noches'; + }; + + if (isLoading) { return ( -
-

Error: {error}

- +
+
+
+

Cargando datos de tu panadería...

+
); } - // Sample historical data for charts (you can move this to the hook later) - const salesHistory = [ - { date: '2024-10-28', ventas: 145, prediccion: 140 }, - { date: '2024-10-29', ventas: 128, prediccion: 135 }, - { date: '2024-10-30', ventas: 167, prediccion: 160 }, - { date: '2024-10-31', ventas: 143, prediccion: 145 }, - { date: '2024-11-01', ventas: 156, prediccion: 150 }, - { date: '2024-11-02', ventas: 189, prediccion: 185 }, - { date: '2024-11-03', ventas: 134, prediccion: 130 }, - ]; - - const topProducts = [ - { name: 'Croissants', quantity: 45, trend: 'up' }, - { name: 'Pan de molde', quantity: 32, trend: 'up' }, - { name: 'Baguettes', quantity: 28, trend: 'down' }, - { name: 'Napolitanas', quantity: 23, trend: 'up' }, - { name: 'Café', quantity: 67, trend: 'up' }, - ]; + if (error) { + return ( +
+

Error al cargar datos

+

{error}

+ +
+ ); + } return ( -
- {/* Header */} -
-
-

- {/* ¡Hola, {user.fullName?.split(' ')[0] || 'Usuario'}! 👋 */} - Hola -

-

- Aquí tienes un resumen de tu panadería para hoy -

+
+ {/* Welcome Header */} +
+
+
+

+ {getGreeting()}! 👋 +

+

+ {new Date().toLocaleDateString('es-ES', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })} +

+
+ +
+ {weather && ( +
+ + {weather.precipitation > 0 ? '🌧️' : weather.temperature > 20 ? '☀️' : '⛅'} + + {weather.temperature}°C +
+ )} + +
+
Estado del sistema
+
+
+ Operativo +
+
+
+
+ + {/* Critical Section - Always Visible */} +
+ {/* Revenue - Most Important */} + - {weather && ( -
- - {weather.temperature}°C - {weather.description} -
- )} + {/* Alerts - Real API Data */} + + + {/* Quick Actions - Easy Access */} + { + console.log('Action clicked:', actionId); + // Handle quick actions + switch (actionId) { + case 'view_orders': + onNavigateToOrders?.(); + break; + case 'view_sales': + onNavigateToReports?.(); + break; + default: + // Handle other actions + break; + } + }} + />
- {/* Key Metrics */} -
-
-
-
- -
-
-

Ventas de Hoy

-

{metrics?.totalSales ?? 0}

-

- - +12% vs ayer -

-
-
-
+ {/* Order Suggestions - Real AI-Powered Recommendations */} + { + console.log('Update order quantity:', orderId, quantity, type); + // In real implementation, this would update the backend + }} + onCreateOrder={(items, type) => { + console.log('Create order:', type, items); + // Navigate to orders page to complete the order + onNavigateToOrders?.(); + }} + onViewDetails={() => { + onNavigateToOrders?.(); + }} + /> -
-
-
- -
-
-

Reducción Desperdicio

-

{metrics?.wasteReduction ?? 0}%

-

- - Mejorando -

-
-
-
+ {/* Production Section - Core Operations */} + { + console.log('Update quantity:', itemId, quantity); + }} + onUpdateStatus={(itemId: string, status: any) => { + console.log('Update status:', itemId, status); + }} + onViewDetails={() => { + onNavigateToProduction?.(); + }} + /> -
-
-
- -
-
-

Precisión IA

-

{metrics?.accuracy ?? 0}%

-

- - Excelente -

-
-
-
+ {/* Quick Overview - Supporting Information */} + -
-
-
- -
-
-

Roturas Stock

-

{metrics?.stockouts ?? 0}

-

- - Reduciendo -

-
-
-
-
- - {/* Main Content Grid */} -
- {/* Sales Chart */} -
-

- Ventas vs Predicciones (Última Semana) -

-
- - - - { - const date = new Date(value); - return `${date.getDate()}/${date.getMonth() + 1}`; - }} - /> - - { - const date = new Date(value); - return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`; - }} - /> - - - - -
-
- - {/* Today's Forecasts */} -
-

- Predicciones para Hoy -

-
- {todayForecasts.map((forecast, index) => ( -
-
-
- {forecast.product} - - {getConfidenceLabel(forecast.confidence)} - -
-
- - {forecast.predicted} - - = 0 ? 'text-success-600' : 'text-danger-600' - }`}> - {forecast.change >= 0 ? ( - - ) : ( - - )} - {Math.abs(forecast.change)} vs ayer - -
-
-
- ))} -
-
-
- - {/* Bottom Section */} -
- {/* Top Products */} -
-

- Productos Más Vendidos (Esta Semana) -

-
- - - - - - - - - -
-
- - {/* Quick Actions */} -
-

- Acciones Rápidas -

-
- - - - - -
-
-
- - {/* Weather Impact Alert */} + {/* Weather Impact Alert - Context Aware */} {weather && weather.precipitation > 0 && (
- + 🌧️
-

Impacto del Clima

+

Impacto del Clima Detectado

- Se esperan precipitaciones hoy. Esto puede reducir el tráfico peatonal en un 20-30%. - Considera ajustar la producción de productos frescos. + Se esperan precipitaciones ({weather.precipitation}mm). Las predicciones se han ajustado + automáticamente considerando una reducción del 15% en el tráfico.

+
+
+ Producción y pedidos ya optimizados +
)} + + {/* Success Message - When Everything is Good */} + {realAlerts.length === 0 && ( +
+
🎉
+

¡Todo bajo control!

+

+ No hay alertas activas. Tu panadería está funcionando perfectamente. +

+
+ )}
); }; diff --git a/frontend/src/pages/orders/OrdersPage.tsx b/frontend/src/pages/orders/OrdersPage.tsx index 511b5518..0b3306d2 100644 --- a/frontend/src/pages/orders/OrdersPage.tsx +++ b/frontend/src/pages/orders/OrdersPage.tsx @@ -1,5 +1,9 @@ import React, { useState, useEffect } from 'react'; -import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock } from 'lucide-react'; +import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock, BarChart3, TrendingUp, Euro, Settings } from 'lucide-react'; + +// Import complex components +import WhatIfPlanner from '../../components/ui/WhatIfPlanner'; +import DemandHeatmap from '../../components/ui/DemandHeatmap'; interface Order { id: string; @@ -24,7 +28,7 @@ const OrdersPage: React.FC = () => { const [orders, setOrders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [showNewOrder, setShowNewOrder] = useState(false); - const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'delivered'>('all'); + const [activeTab, setActiveTab] = useState<'orders' | 'analytics' | 'forecasting' | 'suppliers'>('orders'); // Sample orders data const sampleOrders: Order[] = [ @@ -135,11 +139,47 @@ const OrdersPage: React.FC = () => { } }; + // Sample data for complex components + const orderDemandHeatmapData = [ + { + weekStart: '2024-11-04', + days: [ + { + date: '2024-11-04', + demand: 180, + isToday: true, + products: [ + { name: 'Harina de trigo', demand: 50, confidence: 'high' as const }, + { name: 'Levadura fresca', demand: 2, confidence: 'high' as const }, + { name: 'Mantequilla', demand: 5, confidence: 'medium' as const }, + { name: 'Vasos café', demand: 1000, confidence: 'medium' as const }, + ] + }, + { date: '2024-11-05', demand: 165, isForecast: true }, + { date: '2024-11-06', demand: 195, isForecast: true }, + { date: '2024-11-07', demand: 220, isForecast: true }, + { date: '2024-11-08', demand: 185, isForecast: true }, + { date: '2024-11-09', demand: 250, isForecast: true }, + { date: '2024-11-10', demand: 160, isForecast: true } + ] + } + ]; + + const baselineSupplyData = { + totalDemand: 180, + totalRevenue: 420, + products: [ + { name: 'Harina de trigo', demand: 50, price: 0.85 }, + { name: 'Levadura fresca', demand: 2, price: 3.20 }, + { name: 'Mantequilla', demand: 5, price: 4.20 }, + { name: 'Leche entera', demand: 20, price: 0.95 }, + { name: 'Vasos café', demand: 1000, price: 0.08 }, + ] + }; + const filteredOrders = orders.filter(order => { - if (activeTab === 'all') return true; - if (activeTab === 'pending') return order.status === 'pending' || order.status === 'confirmed'; - if (activeTab === 'delivered') return order.status === 'delivered'; - return true; + if (activeTab === 'orders') return true; + return false; }); const handleDeleteOrder = (orderId: string) => { @@ -181,24 +221,31 @@ const OrdersPage: React.FC = () => {
- {/* Tabs */} + {/* Enhanced Tabs */}
{[ - { id: 'all', label: 'Todos', count: orders.length }, - { id: 'pending', label: 'Pendientes', count: orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length }, - { id: 'delivered', label: 'Entregados', count: orders.filter(o => o.status === 'delivered').length } + { id: 'orders', label: 'Gestión de Pedidos', icon: Package, count: orders.length }, + { id: 'analytics', label: 'Análisis', icon: BarChart3 }, + { id: 'forecasting', label: 'Simulaciones', icon: TrendingUp }, + { id: 'suppliers', label: 'Proveedores', icon: Settings } ].map((tab) => ( ))}
@@ -224,8 +271,11 @@ const OrdersPage: React.FC = () => {
- {/* Orders Grid */} -
+ {/* Tab Content */} + {activeTab === 'orders' && ( + <> + {/* Orders Grid */} +
{filteredOrders.map((order) => (
{/* Order Header */} @@ -390,6 +440,148 @@ const OrdersPage: React.FC = () => {
+ + )} + + {/* Analytics Tab */} + {activeTab === 'analytics' && ( +
+ { + console.log('Selected date:', date); + }} + /> + + {/* Cost Analysis Chart */} +
+

+ + Análisis de Costos +

+
+
+
Ahorro Mensual
+
€124.50
+
vs mes anterior
+
+
+
Gasto Promedio
+
€289.95
+
por pedido
+
+
+
Eficiencia
+
94.2%
+
predicción IA
+
+
+ +
+
+ +

Gráfico de tendencias de costos

+

Próximamente disponible

+
+
+
+
+ )} + + {/* Forecasting/Simulations Tab */} + {activeTab === 'forecasting' && ( +
+ { + console.log('Scenario run:', scenario, result); + }} + /> +
+ )} + + {/* Suppliers Tab */} + {activeTab === 'suppliers' && ( +
+ {/* Suppliers Management */} +
+

+ + Gestión de Proveedores +

+ +
+ {[ + { + name: 'Harinas Castellana', + category: 'Ingredientes', + rating: 4.8, + reliability: 98, + nextDelivery: '2024-11-05', + status: 'active' + }, + { + name: 'Distribuciones Madrid', + category: 'Consumibles', + rating: 4.5, + reliability: 95, + nextDelivery: '2024-11-04', + status: 'active' + }, + { + name: 'Lácteos Frescos SA', + category: 'Ingredientes', + rating: 4.9, + reliability: 99, + nextDelivery: '2024-11-03', + status: 'active' + } + ].map((supplier, index) => ( +
+
+

{supplier.name}

+ + {supplier.status === 'active' ? 'Activo' : 'Inactivo'} + +
+ +
+
+ Categoría: {supplier.category} +
+
+ Calificación: ⭐ {supplier.rating}/5 +
+
+ Confiabilidad: {supplier.reliability}% +
+
+ Próxima entrega: {new Date(supplier.nextDelivery).toLocaleDateString('es-ES')} +
+
+ +
+ + +
+
+ ))} +
+ +
+ +
+
+
+ )} {/* New Order Modal Placeholder */} {showNewOrder && ( diff --git a/frontend/src/pages/production/ProductionPage.tsx b/frontend/src/pages/production/ProductionPage.tsx new file mode 100644 index 00000000..9a1f531a --- /dev/null +++ b/frontend/src/pages/production/ProductionPage.tsx @@ -0,0 +1,671 @@ +import React, { useState, useEffect } from 'react'; +import { + Clock, Calendar, ChefHat, TrendingUp, AlertTriangle, + CheckCircle, Settings, Plus, BarChart3, Users, + Timer, Target, Activity, Zap +} from 'lucide-react'; + +// Import existing complex components +import ProductionSchedule from '../../components/ui/ProductionSchedule'; +import DemandHeatmap from '../../components/ui/DemandHeatmap'; +import { useDashboard } from '../../hooks/useDashboard'; + +// Types for production management +interface ProductionMetrics { + efficiency: number; + onTimeCompletion: number; + wastePercentage: number; + energyUsage: number; + staffUtilization: number; +} + +interface ProductionBatch { + id: string; + product: string; + batchSize: number; + startTime: string; + endTime: string; + status: 'planned' | 'in_progress' | 'completed' | 'delayed'; + assignedStaff: string[]; + actualYield: number; + expectedYield: number; + notes?: string; + temperature?: number; + humidity?: number; +} + +interface StaffMember { + id: string; + name: string; + role: 'baker' | 'assistant' | 'decorator'; + currentTask?: string; + status: 'available' | 'busy' | 'break'; + efficiency: number; +} + +interface Equipment { + id: string; + name: string; + type: 'oven' | 'mixer' | 'proofer' | 'cooling_rack'; + status: 'idle' | 'in_use' | 'maintenance' | 'error'; + currentBatch?: string; + temperature?: number; + maintenanceDue?: string; +} + +const ProductionPage: React.FC = () => { + const { todayForecasts, metrics, weather, isLoading } = useDashboard(); + const [activeTab, setActiveTab] = useState<'schedule' | 'batches' | 'analytics' | 'staff' | 'equipment'>('schedule'); + const [productionMetrics, setProductionMetrics] = useState({ + efficiency: 87.5, + onTimeCompletion: 94.2, + wastePercentage: 3.8, + energyUsage: 156.7, + staffUtilization: 78.3 + }); + + // Sample production schedule data + const [productionSchedule, setProductionSchedule] = useState([ + { + time: '05:00 AM', + items: [ + { + id: 'prod-1', + product: 'Croissants', + quantity: 48, + priority: 'high' as const, + estimatedTime: 180, + status: 'in_progress' as const, + confidence: 0.92, + notes: 'Alta demanda prevista - lote doble' + }, + { + id: 'prod-2', + product: 'Pan de molde', + quantity: 35, + priority: 'high' as const, + estimatedTime: 240, + status: 'pending' as const, + confidence: 0.88 + } + ], + totalTime: 420 + }, + { + time: '08:00 AM', + items: [ + { + id: 'prod-3', + product: 'Baguettes', + quantity: 25, + priority: 'medium' as const, + estimatedTime: 200, + status: 'pending' as const, + confidence: 0.75 + }, + { + id: 'prod-4', + product: 'Magdalenas', + quantity: 60, + priority: 'medium' as const, + estimatedTime: 120, + status: 'pending' as const, + confidence: 0.82 + } + ], + totalTime: 320 + } + ]); + + const [productionBatches, setProductionBatches] = useState([ + { + id: 'batch-1', + product: 'Croissants', + batchSize: 48, + startTime: '05:00', + endTime: '08:00', + status: 'in_progress', + assignedStaff: ['maria-lopez', 'carlos-ruiz'], + actualYield: 45, + expectedYield: 48, + temperature: 180, + humidity: 65, + notes: 'Masa fermentando correctamente' + }, + { + id: 'batch-2', + product: 'Pan de molde', + batchSize: 35, + startTime: '06:30', + endTime: '10:30', + status: 'planned', + assignedStaff: ['ana-garcia'], + actualYield: 0, + expectedYield: 35, + notes: 'Esperando finalización de croissants' + } + ]); + + const [staff, setStaff] = useState([ + { + id: 'maria-lopez', + name: 'María López', + role: 'baker', + currentTask: 'Preparando croissants', + status: 'busy', + efficiency: 94.2 + }, + { + id: 'carlos-ruiz', + name: 'Carlos Ruiz', + role: 'assistant', + currentTask: 'Horneando croissants', + status: 'busy', + efficiency: 87.8 + }, + { + id: 'ana-garcia', + name: 'Ana García', + role: 'baker', + status: 'available', + efficiency: 91.5 + } + ]); + + const [equipment, setEquipment] = useState([ + { + id: 'oven-1', + name: 'Horno Principal', + type: 'oven', + status: 'in_use', + currentBatch: 'batch-1', + temperature: 180, + maintenanceDue: '2024-11-15' + }, + { + id: 'mixer-1', + name: 'Amasadora Industrial', + type: 'mixer', + status: 'idle', + maintenanceDue: '2024-11-20' + }, + { + id: 'proofer-1', + name: 'Fermentadora', + type: 'proofer', + status: 'in_use', + currentBatch: 'batch-2', + temperature: 28, + maintenanceDue: '2024-12-01' + } + ]); + + // Demand heatmap sample data + const heatmapData = [ + { + weekStart: '2024-11-04', + days: [ + { + date: '2024-11-04', + demand: 180, + isToday: true, + products: [ + { name: 'Croissants', demand: 48, confidence: 'high' as const }, + { name: 'Pan de molde', demand: 35, confidence: 'high' as const }, + { name: 'Baguettes', demand: 25, confidence: 'medium' as const }, + { name: 'Magdalenas', demand: 32, confidence: 'medium' as const }, + ] + }, + { + date: '2024-11-05', + demand: 165, + isForecast: true, + products: [ + { name: 'Croissants', demand: 42, confidence: 'high' as const }, + { name: 'Pan de molde', demand: 38, confidence: 'medium' as const }, + { name: 'Baguettes', demand: 28, confidence: 'medium' as const }, + { name: 'Magdalenas', demand: 28, confidence: 'low' as const }, + ] + }, + { + date: '2024-11-06', + demand: 195, + isForecast: true, + products: [ + { name: 'Croissants', demand: 55, confidence: 'high' as const }, + { name: 'Pan de molde', demand: 40, confidence: 'high' as const }, + { name: 'Baguettes', demand: 32, confidence: 'medium' as const }, + { name: 'Magdalenas', demand: 35, confidence: 'medium' as const }, + ] + }, + { date: '2024-11-07', demand: 220, isForecast: true }, + { date: '2024-11-08', demand: 185, isForecast: true }, + { date: '2024-11-09', demand: 250, isForecast: true }, + { date: '2024-11-10', demand: 160, isForecast: true } + ] + } + ]; + + const getStatusColor = (status: string) => { + switch (status) { + case 'planned': + return 'bg-blue-100 text-blue-800'; + case 'in_progress': + return 'bg-yellow-100 text-yellow-800'; + case 'completed': + return 'bg-green-100 text-green-800'; + case 'delayed': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const getEquipmentStatusColor = (status: Equipment['status']) => { + switch (status) { + case 'idle': + return 'bg-gray-100 text-gray-800'; + case 'in_use': + return 'bg-green-100 text-green-800'; + case 'maintenance': + return 'bg-yellow-100 text-yellow-800'; + case 'error': + return 'bg-red-100 text-red-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + if (isLoading) { + return ( +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ + Centro de Producción +

+

+ Gestión completa de la producción diaria y planificación inteligente +

+
+ +
+
+
Eficiencia Hoy
+
{productionMetrics.efficiency}%
+
+ + +
+
+
+ + {/* Key Metrics Cards */} +
+
+
+
+

Eficiencia

+

{productionMetrics.efficiency}%

+
+
+ +
+
+
+ + +2.3% vs ayer +
+
+ +
+
+
+

A Tiempo

+

{productionMetrics.onTimeCompletion}%

+
+
+ +
+
+
+ + Muy bueno +
+
+ +
+
+
+

Desperdicio

+

{productionMetrics.wastePercentage}%

+
+
+ +
+
+
+ + -0.5% vs ayer +
+
+ +
+
+
+

Energía

+

{productionMetrics.energyUsage} kW

+
+
+ +
+
+
+ + Normal +
+
+ +
+
+
+

Personal

+

{productionMetrics.staffUtilization}%

+
+
+ +
+
+
+ + 3/4 activos +
+
+
+ + {/* Tabs Navigation */} +
+
+ {[ + { id: 'schedule', label: 'Programa', icon: Calendar }, + { id: 'batches', label: 'Lotes Activos', icon: Timer }, + { id: 'analytics', label: 'Análisis', icon: BarChart3 }, + { id: 'staff', label: 'Personal', icon: Users }, + { id: 'equipment', label: 'Equipos', icon: Settings } + ].map((tab) => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {activeTab === 'schedule' && ( + <> + { + setProductionSchedule(prev => + prev.map(slot => ({ + ...slot, + items: slot.items.map(item => + item.id === itemId ? { ...item, quantity } : item + ) + })) + ); + }} + onUpdateStatus={(itemId, status) => { + setProductionSchedule(prev => + prev.map(slot => ({ + ...slot, + items: slot.items.map(item => + item.id === itemId ? { ...item, status } : item + ) + })) + ); + }} + /> + + )} + + {activeTab === 'batches' && ( +
+ {productionBatches.map((batch) => ( +
+
+

{batch.product}

+ + {batch.status === 'planned' ? 'Planificado' : + batch.status === 'in_progress' ? 'En Progreso' : + batch.status === 'completed' ? 'Completado' : 'Retrasado'} + +
+ +
+
+
+

Tamaño del Lote

+

{batch.batchSize} unidades

+
+
+

Rendimiento

+

+ {batch.actualYield || 0}/{batch.expectedYield} +

+
+
+ +
+
+

Inicio

+

{batch.startTime}

+
+
+

Fin Estimado

+

{batch.endTime}

+
+
+ + {(batch.temperature || batch.humidity) && ( +
+ {batch.temperature && ( +
+

Temperatura

+

{batch.temperature}°C

+
+ )} + {batch.humidity && ( +
+

Humedad

+

{batch.humidity}%

+
+ )} +
+ )} + +
+

Personal Asignado

+
+ {batch.assignedStaff.map((staffId) => { + const staffMember = staff.find(s => s.id === staffId); + return ( + + {staffMember?.name || staffId} + + ); + })} +
+
+ + {batch.notes && ( +
+

{batch.notes}

+
+ )} +
+
+ ))} +
+ )} + + {activeTab === 'analytics' && ( +
+ { + console.log('Selected date:', date); + }} + /> + + {/* Production Trends Chart Placeholder */} +
+

+ + Tendencias de Producción +

+
+
+ +

Gráfico de tendencias de producción

+

Próximamente disponible

+
+
+
+
+ )} + + {activeTab === 'staff' && ( +
+ {staff.map((member) => ( +
+
+

{member.name}

+ + {member.status === 'available' ? 'Disponible' : + member.status === 'busy' ? 'Ocupado' : 'Descanso'} + +
+ +
+
+

Rol

+

{member.role}

+
+ + {member.currentTask && ( +
+

Tarea Actual

+

{member.currentTask}

+
+ )} + +
+

Eficiencia

+
+
+
+
+ {member.efficiency}% +
+
+
+
+ ))} +
+ )} + + {activeTab === 'equipment' && ( +
+ {equipment.map((item) => ( +
+
+

{item.name}

+ + {item.status === 'idle' ? 'Inactivo' : + item.status === 'in_use' ? 'En Uso' : + item.status === 'maintenance' ? 'Mantenimiento' : 'Error'} + +
+ +
+
+

Tipo

+

{item.type}

+
+ + {item.currentBatch && ( +
+

Lote Actual

+

+ {productionBatches.find(b => b.id === item.currentBatch)?.product || item.currentBatch} +

+
+ )} + + {item.temperature && ( +
+

Temperatura

+

{item.temperature}°C

+
+ )} + + {item.maintenanceDue && ( +
+

Próximo Mantenimiento

+

+ {new Date(item.maintenanceDue).toLocaleDateString('es-ES')} +

+
+ )} +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default ProductionPage; \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..cbc4e4ec --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": false, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..decf631a --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.js"] +} \ No newline at end of file diff --git a/infrastructure/docker/production/docker-compose.prod.yml b/infrastructure/docker/production/docker-compose.prod.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/infrastructure/kubernetes/base/forecasting-service.yaml b/infrastructure/kubernetes/base/forecasting-service.yaml deleted file mode 100644 index e52d8bf3..00000000 --- a/infrastructure/kubernetes/base/forecasting-service.yaml +++ /dev/null @@ -1,78 +0,0 @@ -# ================================================================ -# Kubernetes Deployment: infrastructure/kubernetes/base/forecasting-service.yaml -# ================================================================ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: forecasting-service - labels: - app: forecasting-service -spec: - replicas: 2 - selector: - matchLabels: - app: forecasting-service - template: - metadata: - labels: - app: forecasting-service - spec: - containers: - - name: forecasting-service - image: bakery-forecasting/forecasting-service:latest - ports: - - containerPort: 8000 - env: - - name: DATABASE_URL - valueFrom: - secretKeyRef: - name: forecasting-db-secret - key: database-url - - name: RABBITMQ_URL - valueFrom: - secretKeyRef: - name: rabbitmq-secret - key: url - - name: REDIS_URL - valueFrom: - secretKeyRef: - name: redis-secret - key: url - - name: TRAINING_SERVICE_URL - value: "http://training-service:8000" - - name: DATA_SERVICE_URL - value: "http://data-service:8000" - resources: - requests: - memory: "512Mi" - cpu: "250m" - limits: - memory: "1Gi" - cpu: "500m" - livenessProbe: - httpGet: - path: /health - port: 8000 - initialDelaySeconds: 30 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /health - port: 8000 - initialDelaySeconds: 5 - periodSeconds: 5 ---- -apiVersion: v1 -kind: Service -metadata: - name: forecasting-service - labels: - app: forecasting-service -spec: - selector: - app: forecasting-service - ports: - - port: 8000 - targetPort: 8000 - type: ClusterIP - diff --git a/microservices_project_structure.md b/microservices_project_structure.md deleted file mode 100644 index 84b5d908..00000000 --- a/microservices_project_structure.md +++ /dev/null @@ -1,399 +0,0 @@ -# Complete Microservices Project Structure - -## 📁 Project Root Structure - -``` -bakery-forecasting-platform/ -├── README.md -├── docker-compose.yml # Development environment -├── docker-compose.prod.yml # Production environment -├── .env.example # Environment variables template -├── .gitignore # Git ignore file -├── docs/ # Documentation -│ ├── architecture/ # Architecture diagrams -│ ├── api/ # API documentation -│ └── deployment/ # Deployment guides -├── scripts/ # Utility scripts -│ ├── setup.sh # Development setup -│ ├── test.sh # Run all tests -│ └── deploy.sh # Deployment script -├── gateway/ # API Gateway Service -│ ├── Dockerfile -│ ├── requirements.txt -│ ├── app/ -│ │ ├── __init__.py -│ │ ├── main.py -│ │ ├── core/ -│ │ │ ├── __init__.py -│ │ │ ├── config.py -│ │ │ ├── auth.py -│ │ │ └── service_discovery.py -│ │ ├── middleware/ -│ │ │ ├── __init__.py -│ │ │ ├── cors.py -│ │ │ ├── auth.py -│ │ │ └── logging.py -│ │ └── routes/ -│ │ ├── __init__.py -│ │ ├── auth.py -│ │ ├── training.py -│ │ ├── forecasting.py -│ │ └── data.py -│ └── tests/ -│ ├── __init__.py -│ ├── test_gateway.py -│ └── test_routes.py -├── services/ # Individual Microservices -│ ├── auth/ # Authentication Service -│ │ ├── Dockerfile -│ │ ├── requirements.txt -│ │ ├── app/ -│ │ │ ├── __init__.py -│ │ │ ├── main.py -│ │ │ ├── core/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── config.py -│ │ │ │ ├── database.py -│ │ │ │ ├── security.py -│ │ │ │ └── auth.py -│ │ │ ├── models/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── users.py -│ │ │ │ └── tokens.py -│ │ │ ├── schemas/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── users.py -│ │ │ │ └── auth.py -│ │ │ ├── services/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── auth_service.py -│ │ │ │ └── user_service.py -│ │ │ └── api/ -│ │ │ ├── __init__.py -│ │ │ ├── auth.py -│ │ │ └── users.py -│ │ ├── migrations/ -│ │ │ └── versions/ -│ │ └── tests/ -│ │ ├── __init__.py -│ │ ├── conftest.py -│ │ ├── test_auth.py -│ │ └── test_users.py -│ ├── training/ # ML Training Service -│ │ ├── Dockerfile -│ │ ├── requirements.txt -│ │ ├── app/ -│ │ │ ├── __init__.py -│ │ │ ├── main.py -│ │ │ ├── core/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── config.py -│ │ │ │ ├── database.py -│ │ │ │ └── auth.py -│ │ │ ├── models/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── training.py -│ │ │ │ └── models.py -│ │ │ ├── schemas/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── training.py -│ │ │ │ └── models.py -│ │ │ ├── services/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── training_service.py -│ │ │ │ ├── model_service.py -│ │ │ │ └── messaging.py -│ │ │ ├── ml/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── trainer.py -│ │ │ │ ├── prophet_manager.py -│ │ │ │ └── data_processor.py -│ │ │ └── api/ -│ │ │ ├── __init__.py -│ │ │ ├── training.py -│ │ │ └── models.py -│ │ ├── migrations/ -│ │ │ └── versions/ -│ │ └── tests/ -│ │ ├── __init__.py -│ │ ├── conftest.py -│ │ ├── test_training.py -│ │ └── test_ml.py -│ ├── forecasting/ # Forecasting Service -│ │ ├── Dockerfile -│ │ ├── requirements.txt -│ │ ├── app/ -│ │ │ ├── __init__.py -│ │ │ ├── main.py -│ │ │ ├── core/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── config.py -│ │ │ │ ├── database.py -│ │ │ │ └── auth.py -│ │ │ ├── models/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── forecasts.py -│ │ │ │ └── predictions.py -│ │ │ ├── schemas/ -│ │ │ │ ├── __init__.py -│ │ │ │ └── forecasts.py -│ │ │ ├── services/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── forecasting_service.py -│ │ │ │ ├── prediction_service.py -│ │ │ │ └── messaging.py -│ │ │ ├── ml/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── predictor.py -│ │ │ │ └── model_loader.py -│ │ │ └── api/ -│ │ │ ├── __init__.py -│ │ │ ├── forecasts.py -│ │ │ └── predictions.py -│ │ ├── migrations/ -│ │ │ └── versions/ -│ │ └── tests/ -│ │ ├── __init__.py -│ │ ├── conftest.py -│ │ ├── test_forecasting.py -│ │ └── test_predictions.py -│ ├── data/ # Data Service (External APIs) -│ │ ├── Dockerfile -│ │ ├── requirements.txt -│ │ ├── app/ -│ │ │ ├── __init__.py -│ │ │ ├── main.py -│ │ │ ├── core/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── config.py -│ │ │ │ ├── database.py -│ │ │ │ └── auth.py -│ │ │ ├── models/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── sales.py -│ │ │ │ ├── weather.py -│ │ │ │ └── traffic.py -│ │ │ ├── schemas/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── sales.py -│ │ │ │ └── external.py -│ │ │ ├── services/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── sales_service.py -│ │ │ │ ├── weather_service.py -│ │ │ │ ├── traffic_service.py -│ │ │ │ └── data_import_service.py -│ │ │ ├── external/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── aemet.py -│ │ │ │ ├── madrid_opendata.py -│ │ │ │ └── base_client.py -│ │ │ └── api/ -│ │ │ ├── __init__.py -│ │ │ ├── sales.py -│ │ │ ├── weather.py -│ │ │ └── traffic.py -│ │ ├── migrations/ -│ │ │ └── versions/ -│ │ └── tests/ -│ │ ├── __init__.py -│ │ ├── conftest.py -│ │ ├── test_data.py -│ │ └── test_external.py -│ ├── notification/ # Notification Service -│ │ ├── Dockerfile -│ │ ├── requirements.txt -│ │ ├── app/ -│ │ │ ├── __init__.py -│ │ │ ├── main.py -│ │ │ ├── core/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── config.py -│ │ │ │ ├── database.py -│ │ │ │ └── auth.py -│ │ │ ├── models/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── notifications.py -│ │ │ │ └── templates.py -│ │ │ ├── schemas/ -│ │ │ │ ├── __init__.py -│ │ │ │ └── notifications.py -│ │ │ ├── services/ -│ │ │ │ ├── __init__.py -│ │ │ │ ├── notification_service.py -│ │ │ │ ├── email_service.py -│ │ │ │ ├── whatsapp_service.py -│ │ │ │ └── messaging.py -│ │ │ └── api/ -│ │ │ ├── __init__.py -│ │ │ └── notifications.py -│ │ ├── migrations/ -│ │ │ └── versions/ -│ │ └── tests/ -│ │ ├── __init__.py -│ │ ├── conftest.py -│ │ └── test_notifications.py -│ └── tenant/ # Tenant Management Service -│ ├── Dockerfile -│ ├── requirements.txt -│ ├── app/ -│ │ ├── __init__.py -│ │ ├── main.py -│ │ ├── core/ -│ │ │ ├── __init__.py -│ │ │ ├── config.py -│ │ │ ├── database.py -│ │ │ └── auth.py -│ │ ├── models/ -│ │ │ ├── __init__.py -│ │ │ ├── tenants.py -│ │ │ └── subscriptions.py -│ │ ├── schemas/ -│ │ │ ├── __init__.py -│ │ │ └── tenants.py -│ │ ├── services/ -│ │ │ ├── __init__.py -│ │ │ ├── tenant_service.py -│ │ │ ├── subscription_service.py -│ │ │ └── messaging.py -│ │ └── api/ -│ │ ├── __init__.py -│ │ └── tenants.py -│ ├── migrations/ -│ │ └── versions/ -│ └── tests/ -│ ├── __init__.py -│ ├── conftest.py -│ └── test_tenants.py -├── shared/ # Shared Libraries -│ ├── __init__.py -│ ├── auth/ -│ │ ├── __init__.py -│ │ ├── jwt_handler.py -│ │ └── decorators.py -│ ├── database/ -│ │ ├── __init__.py -│ │ ├── base.py -│ │ └── utils.py -│ ├── messaging/ -│ │ ├── __init__.py -│ │ ├── rabbitmq.py -│ │ └── events.py -│ ├── monitoring/ -│ │ ├── __init__.py -│ │ ├── logging.py -│ │ └── metrics.py -│ └── utils/ -│ ├── __init__.py -│ ├── datetime_utils.py -│ └── validation.py -├── frontend/ # Frontend Applications -│ ├── dashboard/ # React Dashboard -│ │ ├── package.json -│ │ ├── package-lock.json -│ │ ├── Dockerfile -│ │ ├── public/ -│ │ ├── src/ -│ │ │ ├── components/ -│ │ │ ├── pages/ -│ │ │ ├── services/ -│ │ │ ├── hooks/ -│ │ │ └── utils/ -│ │ └── tests/ -│ └── marketing/ # Next.js Marketing Site -│ ├── package.json -│ ├── package-lock.json -│ ├── Dockerfile -│ ├── public/ -│ ├── src/ -│ │ ├── components/ -│ │ ├── pages/ -│ │ ├── styles/ -│ │ └── utils/ -│ └── tests/ -├── infrastructure/ # Infrastructure as Code -│ ├── docker/ -│ │ ├── postgres/ -│ │ ├── redis/ -│ │ └── rabbitmq/ -│ ├── kubernetes/ -│ │ ├── base/ -│ │ ├── dev/ -│ │ ├── staging/ -│ │ └── production/ -│ ├── terraform/ -│ │ ├── modules/ -│ │ ├── environments/ -│ │ └── global/ -│ └── monitoring/ -│ ├── prometheus/ -│ ├── grafana/ -│ └── elk/ -├── deployment/ # Deployment Configurations -│ ├── docker-compose.dev.yml -│ ├── docker-compose.staging.yml -│ ├── docker-compose.prod.yml -│ ├── nginx/ -│ │ ├── nginx.conf -│ │ └── ssl/ -│ └── ssl/ -└── tests/ # Integration Tests - ├── __init__.py - ├── integration/ - │ ├── __init__.py - │ ├── test_auth_flow.py - │ ├── test_training_flow.py - │ └── test_forecasting_flow.py - ├── e2e/ - │ ├── __init__.py - │ └── test_complete_flow.py - └── performance/ - ├── __init__.py - └── test_load.py -``` - -## 🔧 Key Changes from Monolithic Structure - -### 1. **Service Separation** -- Each service has its own database -- Independent deployment and scaling -- Service-specific dependencies - -### 2. **API Gateway** -- Single entry point for all clients -- Request routing to appropriate services -- Cross-cutting concerns (auth, logging, rate limiting) - -### 3. **Shared Libraries** -- Common functionality across services -- Consistent authentication and database patterns -- Reusable utilities - -### 4. **Infrastructure as Code** -- Kubernetes manifests for orchestration -- Terraform for cloud infrastructure -- Docker configurations for all services - -### 5. **Independent Testing** -- Each service has its own test suite -- Integration tests for service communication -- End-to-end tests for complete workflows - -## 📋 Migration Steps - -1. **Create New Project Structure**: Set up all directories and base files -2. **Implement Shared Libraries**: Common functionality first -3. **Build Services One by One**: Start with auth, then data, then training -4. **Set Up API Gateway**: Route requests to services -5. **Configure Infrastructure**: Docker, Kubernetes, monitoring -6. **Migrate Frontend**: Update API calls to use gateway -7. **Set Up CI/CD**: Automated testing and deployment - -## 🚀 Benefits of This Structure - -- **Independent Development**: Teams can work on different services -- **Independent Deployment**: Deploy services without affecting others -- **Technology Flexibility**: Each service can use different technologies -- **Scalability**: Scale services independently based on load -- **Fault Isolation**: Service failures don't cascade -- **Easier Testing**: Unit tests per service, integration tests across services diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index 64a0cc27..00000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -echo "🚀 Deploying Bakery Forecasting Platform..." - -# Build and deploy all services -docker-compose build -docker-compose up -d - -echo "Waiting for services to be healthy..." -sleep 30 - -# Check service health -echo "Checking service health..." -curl -f http://localhost:8000/health || echo "Gateway health check failed" - -echo "✅ Deployment completed" -echo "Gateway: http://localhost:8000" -echo "API Docs: http://localhost:8000/docs" diff --git a/scripts/docker-logs.sh b/scripts/docker-logs.sh deleted file mode 100755 index 90235308..00000000 --- a/scripts/docker-logs.sh +++ /dev/null @@ -1,14 +0,0 @@ -# scripts/docker-logs.sh -#!/bin/bash - -# View logs for specific service or all services - -SERVICE=${1:-"all"} - -if [ "$SERVICE" = "all" ]; then - echo "📋 Showing logs for all services..." - docker-compose logs -f --tail=100 -else - echo "📋 Showing logs for $SERVICE..." - docker-compose logs -f --tail=100 $SERVICE -fi \ No newline at end of file diff --git a/scripts/fix-frontend.sh b/scripts/fix-frontend.sh deleted file mode 100755 index c67693dd..00000000 --- a/scripts/fix-frontend.sh +++ /dev/null @@ -1,201 +0,0 @@ -#!/bin/bash -# scripts/fix-frontend.sh - Frontend troubleshooting script - -set -e - -GREEN='\033[0;32m' -BLUE='\033[0;34m' -RED='\033[0;31m' -YELLOW='\033[0;33m' -NC='\033[0m' - -print_step() { - echo -e "${BLUE}[FRONTEND-FIX]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -# Check if we're in the right directory -if [ ! -f "docker-compose.yml" ]; then - print_error "docker-compose.yml not found. Please run this script from the project root." - exit 1 -fi - -print_step "Starting frontend troubleshooting..." - -# Step 1: Check frontend directory structure -print_step "Checking frontend directory structure..." -if [ ! -d "frontend" ]; then - print_error "Frontend directory not found!" - exit 1 -fi - -cd frontend - -# Step 2: Check package.json exists -if [ ! -f "package.json" ]; then - print_error "package.json not found in frontend directory!" - exit 1 -fi -print_success "package.json found" - -# Step 3: Generate package-lock.json if missing -if [ ! -f "package-lock.json" ]; then - print_warning "package-lock.json not found. Generating..." - npm install - print_success "package-lock.json generated" -else - print_success "package-lock.json found" -fi - -# Step 4: Check for missing directories -print_step "Creating missing directories..." -mkdir -p src/pages -mkdir -p src/components -mkdir -p src/hooks -mkdir -p src/services -mkdir -p src/utils -mkdir -p public -print_success "Directory structure verified" - -# Step 5: Check for App.tsx -if [ ! -f "src/App.tsx" ]; then - print_warning "src/App.tsx not found. Creating basic App.tsx..." - - cat > src/App.tsx << 'EOF' -import React from 'react' -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' - -function App() { - return ( - -
-
-

- 🥖 PanIA Dashboard -

-
-

- Bienvenido a PanIA -

-

- Sistema de predicción de demanda para panaderías en Madrid -

-
-
-
-
- ) -} - -export default App -EOF - print_success "Basic App.tsx created" -else - print_success "App.tsx found" -fi - -# Step 6: Check for index.css -if [ ! -f "src/index.css" ]; then - print_warning "src/index.css not found. Creating basic CSS..." - - cat > src/index.css << 'EOF' -@tailwind base; -@tailwind components; -@tailwind utilities; - -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} -EOF - print_success "Basic index.css created" -else - print_success "index.css found" -fi - -# Step 7: Go back to project root -cd .. - -# Step 8: Stop and remove the frontend container -print_step "Stopping and removing existing frontend container..." -docker-compose stop dashboard 2>/dev/null || true -docker-compose rm -f dashboard 2>/dev/null || true - -# Step 9: Remove the image to force rebuild -print_step "Removing frontend image to force rebuild..." -docker rmi bakery/dashboard:latest 2>/dev/null || true - -# Step 10: Check .env file -if [ ! -f ".env" ]; then - print_warning ".env file not found. Creating from example..." - if [ -f ".env.example" ]; then - cp .env.example .env - print_success ".env created from .env.example" - else - # Create minimal .env - cat > .env << 'EOF' -ENVIRONMENT=development -DASHBOARD_PORT=3000 -GATEWAY_PORT=8000 -IMAGE_TAG=latest -EOF - print_success "Minimal .env created" - fi -fi - -# Step 11: Rebuild the frontend service -print_step "Rebuilding frontend service..." -docker-compose build --no-cache dashboard - -# Step 12: Start the frontend service -print_step "Starting frontend service..." -docker-compose up -d dashboard - -# Step 13: Wait and check status -print_step "Waiting for frontend to start..." -sleep 10 - -# Check if container is running -if docker-compose ps dashboard | grep -q "Up"; then - print_success "Frontend container is running!" - print_step "Checking logs..." - docker-compose logs --tail=20 dashboard - - print_step "Testing frontend health..." - sleep 5 - if curl -f http://localhost:3000 >/dev/null 2>&1; then - print_success "Frontend is accessible at http://localhost:3000" - else - print_warning "Frontend is running but not yet accessible. Check logs above." - fi -else - print_error "Frontend container failed to start. Check logs:" - docker-compose logs dashboard -fi - -print_step "Frontend troubleshooting completed!" -echo "" -echo "Next steps:" -echo "1. Check the frontend at: http://localhost:3000" -echo "2. View logs with: docker-compose logs dashboard" -echo "3. Restart if needed: docker-compose restart dashboard" \ No newline at end of file diff --git a/scripts/health-check.sh b/scripts/health-check.sh deleted file mode 100755 index b7f90e7f..00000000 --- a/scripts/health-check.sh +++ /dev/null @@ -1,90 +0,0 @@ -# scripts/docker-health-check.sh -#!/bin/bash - -# Comprehensive health check for all services - -services=( - "bakery-redis:6379" - "bakery-rabbitmq:15672" - "bakery-gateway:8000" - "bakery-auth-service:8000" - "bakery-tenant-service:8000" - "bakery-training-service:8000" - "bakery-forecasting-service:8000" - "bakery-data-service:8000" - "bakery-notification-service:8000" -) - -echo "🏥 Checking service health..." - -for service_port in "${services[@]}"; do - service=$(echo $service_port | cut -d: -f1) - port=$(echo $service_port | cut -d: -f2) - - if docker ps --format "table {{.Names}}" | grep -q "^$service$"; then - if [ "$service" = "bakery-redis" ]; then - # Redis health check - if docker exec $service redis-cli -a redis_pass123 ping > /dev/null 2>&1; then - echo "✅ $service is healthy" - else - echo "❌ $service is unhealthy" - fi - elif [ "$service" = "bakery-rabbitmq" ]; then - # RabbitMQ health check - if curl -s -u bakery:forecast123 http://localhost:$port/api/health/checks/alarms > /dev/null; then - echo "✅ $service is healthy" - else - echo "❌ $service is unhealthy" - fi - else - # HTTP service health check - container_ip=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $service) - if curl -f -s "http://$container_ip:8000/health" > /dev/null; then - echo "✅ $service is healthy" - else - echo "❌ $service is unhealthy" - fi - fi - else - echo "⚠️ $service is not running" - fi -done - -echo "" -echo "🔍 Checking database connections..." - -databases=("auth-db" "training-db" "forecasting-db" "data-db" "tenant-db" "notification-db") - -for db in "${databases[@]}"; do - if docker ps --format "table {{.Names}}" | grep -q "^bakery-$db$"; then - db_name=$(echo $db | sed 's/-/_/g') - user=$(echo $db | sed 's/-db//' | sed 's/-/_/g')_user - - if docker exec bakery-$db pg_isready -U $user -d $db_name > /dev/null 2>&1; then - echo "✅ bakery-$db is ready" - else - echo "❌ bakery-$db is not ready" - fi - else - echo "⚠️ bakery-$db is not running" - fi -done - -echo "" -echo "📊 Service resource usage:" -docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" $(docker ps --format "{{.Names}}" | grep "^bakery-") - -# scripts/docker-logs.sh -#!/bin/bash - -# View logs for specific service or all services - -SERVICE=${1:-"all"} - -if [ "$SERVICE" = "all" ]; then - echo "📋 Showing logs for all services..." - docker-compose logs -f --tail=100 -else - echo "📋 Showing logs for $SERVICE..." - docker-compose logs -f --tail=100 $SERVICE -fi \ No newline at end of file diff --git a/scripts/restart-service.sh b/scripts/restart-service.sh deleted file mode 100755 index 75c5a025..00000000 --- a/scripts/restart-service.sh +++ /dev/null @@ -1,106 +0,0 @@ -# ================================================================ -# SERVICE RESTART SCRIPT -# scripts/restart-service.sh -# ================================================================ - -#!/bin/bash - -# Restart individual service script - -set -e - -GREEN='\033[0;32m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' - -print_step() { - echo -e "${BLUE}[RESTART]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -restart_service() { - local service=$1 - - print_step "Restarting $service..." - - # Build the service - docker-compose build $service - - # Restart with zero downtime - docker-compose up -d --no-deps --force-recreate $service - - # Wait a bit for the service to start - sleep 5 - - # Check health - local port - case $service in - "gateway") port=8000 ;; - "auth-service") port=8001 ;; - "training-service") port=8002 ;; - "forecasting-service") port=8003 ;; - "data-service") port=8004 ;; - "tenant-service") port=8005 ;; - "notification-service") port=8006 ;; - *) - print_error "Unknown service: $service" - exit 1 - ;; - esac - - # Health check with timeout - local attempts=0 - local max_attempts=12 # 60 seconds total - - while [ $attempts -lt $max_attempts ]; do - if curl -f -s "http://localhost:$port/health" > /dev/null 2>&1; then - print_success "$service is healthy and ready" - return 0 - fi - - attempts=$((attempts + 1)) - echo "Waiting for $service to be healthy... ($attempts/$max_attempts)" - sleep 5 - done - - print_error "$service failed to become healthy within 60 seconds" - return 1 -} - -# Main function -main() { - if [ $# -eq 0 ]; then - echo "Usage: $0 " - echo "" - echo "Available services:" - echo " gateway" - echo " auth-service" - echo " training-service" - echo " forecasting-service" - echo " data-service" - echo " tenant-service" - echo " notification-service" - echo "" - echo "Example: $0 auth-service" - exit 1 - fi - - local service=$1 - - echo "================================================================" - echo "RESTARTING SERVICE: $service" - echo "================================================================" - - restart_service $service -} - -# Run main function -main "$@" \ No newline at end of file diff --git a/scripts/test_unified_auth.sh b/scripts/test_unified_auth.sh deleted file mode 100755 index d08b8247..00000000 --- a/scripts/test_unified_auth.sh +++ /dev/null @@ -1,392 +0,0 @@ -#!/bin/bash - -# ================================================================ -# Complete Authentication Test with Registration - FIXED VERSION -# Tests the full user lifecycle: registration → login → API access -# ================================================================ - -echo "🔐 Testing Complete Authentication System with Registration" -echo "==========================================================" - -# Configuration -API_BASE="http://localhost:8000" -AUTH_BASE="$API_BASE/api/v1/auth" -TEST_EMAIL="test-$(date +%s)@bakery.com" # Unique email for each test -TEST_PASSWORD="SecurePass123!" -TEST_NAME="Test Baker" -# ✅ FIX: Generate a proper UUID for tenant testing (will be replaced after bakery creation) -TENANT_ID=$(uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null || echo "00000000-0000-0000-0000-000000000000") - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Helper function for colored output -log_step() { - echo -e "${BLUE}📍 $1${NC}" -} - -log_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -log_error() { - echo -e "${RED}❌ $1${NC}" -} - -log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -# Helper function to check if service is healthy -check_service_health() { - local service_url=$1 - local service_name=$2 - - log_step "Checking $service_name health..." - - response=$(curl -s -o /dev/null -w "%{http_code}" "$service_url/health") - if [ "$response" = "200" ]; then - log_success "$service_name is healthy" - return 0 - else - log_error "$service_name is not healthy (HTTP $response)" - return 1 - fi -} - -# Check all services are running -log_step "Pre-flight checks..." -echo "" - -# Check API Gateway -if ! check_service_health "$API_BASE" "API Gateway"; then - log_error "API Gateway is not running. Start with: docker-compose up -d" - exit 1 -fi - -# Check Auth Service directly -if ! check_service_health "http://localhost:8001" "Auth Service"; then - log_error "Auth Service is not running. Check: docker-compose logs auth-service" - exit 1 -fi - -# Check Tenant Service -if ! check_service_health "http://localhost:8005" "Tenant Service"; then - log_error "Tenant Service is not running. Check: docker-compose logs tenant-service" - exit 1 -fi - -# Check Data Service -if ! check_service_health "http://localhost:8004" "Data Service"; then - log_warning "Data Service is not running, but continuing with auth tests..." -fi - -# Check Training Service -if ! check_service_health "http://localhost:8002" "Training Service"; then - log_warning "Training Service is not running, but continuing with auth tests..." -fi - -echo "" -log_step "All systems ready! Starting authentication tests..." -echo "" - -# ================================================================ -# STEP 1: USER REGISTRATION -# ================================================================ - -log_step "Step 1: Registering new user" -echo "Email: $TEST_EMAIL" -echo "Password: $TEST_PASSWORD" -echo "" - -REGISTRATION_RESPONSE=$(curl -s -X POST "$AUTH_BASE/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"email\": \"$TEST_EMAIL\", - \"password\": \"$TEST_PASSWORD\", - \"full_name\": \"$TEST_NAME\" - }") - -echo "Registration Response:" -echo "$REGISTRATION_RESPONSE" | jq '.' - -# Check if registration was successful -if echo "$REGISTRATION_RESPONSE" | jq -e '.id' > /dev/null; then - USER_ID=$(echo "$REGISTRATION_RESPONSE" | jq -r '.id') - log_success "User registration successful! User ID: $USER_ID" -else - log_error "User registration failed!" - echo "Response: $REGISTRATION_RESPONSE" - exit 1 -fi - -echo "" - -# ================================================================ -# STEP 2: USER LOGIN -# ================================================================ - -log_step "Step 2: Logging in with new user credentials" - -LOGIN_RESPONSE=$(curl -s -X POST "$AUTH_BASE/login" \ - -H "Content-Type: application/json" \ - -d "{ - \"email\": \"$TEST_EMAIL\", - \"password\": \"$TEST_PASSWORD\" - }") - -echo "Login Response:" -echo "$LOGIN_RESPONSE" | jq '.' - -# Extract access token -if echo "$LOGIN_RESPONSE" | jq -e '.access_token' > /dev/null; then - ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token') - REFRESH_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.refresh_token') - log_success "Login successful! Token obtained: ${ACCESS_TOKEN:0:20}..." -else - log_error "Login failed!" - echo "Response: $LOGIN_RESPONSE" - exit 1 -fi - -echo "" - -# ================================================================ -# STEP 3: ACCESSING PROTECTED ENDPOINTS -# ================================================================ - -log_step "Step 3: Testing protected endpoints with authentication" - -# 3a. Get current user info -log_step "3a. Getting current user profile" - -USER_PROFILE_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/users/me" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - -echo "User Profile Response:" -echo "$USER_PROFILE_RESPONSE" | jq '.' - -if echo "$USER_PROFILE_RESPONSE" | jq -e '.email' > /dev/null; then - log_success "User profile retrieved successfully!" -else - log_warning "User profile endpoint may not be implemented yet" -fi - -echo "" - -# ================================================================ -# STEP 4: TENANT REGISTRATION (BAKERY CREATION) -# ================================================================ - -log_step "Step 4: Registering a bakery/tenant" - -BAKERY_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/tenants/register" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - -d "{ - \"name\": \"Test Bakery $(date +%H%M)\", - \"business_type\": \"bakery\", - \"address\": \"Calle Test 123\", - \"city\": \"Madrid\", - \"postal_code\": \"28001\", - \"phone\": \"+34600123456\" - }") - -echo "Bakery Registration Response:" -echo "$BAKERY_RESPONSE" | jq '.' - -if echo "$BAKERY_RESPONSE" | jq -e '.id' > /dev/null; then - # ✅ FIX: Use the actual tenant ID returned from bakery creation - TENANT_ID=$(echo "$BAKERY_RESPONSE" | jq -r '.id') - log_success "Bakery registration successful! Tenant ID: $TENANT_ID" -else - log_error "Bakery registration failed!" - echo "Response: $BAKERY_RESPONSE" - # Continue with tests using placeholder UUID for other endpoints -fi - -echo "" - -# ================================================================ -# STEP 5: TEST DATA SERVICE WITH TENANT ID -# ================================================================ - -log_step "Step 5: Testing data service through gateway" - -# Only test with valid tenant ID -if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then - DATA_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/data/sales" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "X-Tenant-ID: $TENANT_ID") - - echo "Data Service Response:" - echo "$DATA_RESPONSE" | jq '.' - - if [ "$(echo "$DATA_RESPONSE" | jq -r '.status // "unknown"')" != "error" ]; then - log_success "Data service access successful!" - else - log_warning "Data service returned error (may be expected for new tenant)" - fi -else - log_warning "Skipping data service test - no valid tenant ID" -fi - -echo "" - -# ================================================================ -# STEP 6: TEST TRAINING SERVICE WITH TENANT ID -# ================================================================ - -log_step "Step 6: Testing training service through gateway" - -# Only test with valid tenant ID -if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then - TRAINING_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/training/jobs" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "X-Tenant-ID: $TENANT_ID" \ - -H "Content-Type: application/json" \ - -d '{ - "include_weather": true, - "include_traffic": false, - "min_data_points": 30 - }') - - echo "Training Service Response:" - echo "$TRAINING_RESPONSE" | jq '.' - - if echo "$TRAINING_RESPONSE" | jq -e '.job_id // .message' > /dev/null; then - log_success "Training service access successful!" - else - log_warning "Training service access may have issues" - fi -else - log_warning "Skipping training service test - no valid tenant ID" -fi - -echo "" - -# ================================================================ -# STEP 7: TOKEN REFRESH -# ================================================================ - -log_step "Step 7: Testing token refresh" - -REFRESH_RESPONSE=$(curl -s -X POST "$AUTH_BASE/refresh" \ - -H "Content-Type: application/json" \ - -d "{ - \"refresh_token\": \"$REFRESH_TOKEN\" - }") - -echo "Token Refresh Response:" -echo "$REFRESH_RESPONSE" | jq '.' - -if echo "$REFRESH_RESPONSE" | jq -e '.access_token' > /dev/null; then - NEW_ACCESS_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.access_token') - log_success "Token refresh successful! New token: ${NEW_ACCESS_TOKEN:0:20}..." -else - log_warning "Token refresh may not be fully implemented" -fi - -echo "" - -# ================================================================ -# STEP 8: DIRECT SERVICE HEALTH CHECKS -# ================================================================ - -log_step "Step 8: Testing direct service access (without gateway)" - -# Test auth service directly -log_step "8a. Auth service direct health check" -AUTH_HEALTH=$(curl -s -X GET "http://localhost:8001/health") -echo "Auth Service Health:" -echo "$AUTH_HEALTH" | jq '.' - -# Test other services if available -log_step "8b. Other services health check" - -services=("8002:Training" "8003:Forecasting" "8004:Data" "8005:Tenant" "8006:Notification") - -for service in "${services[@]}"; do - port=$(echo $service | cut -d: -f1) - name=$(echo $service | cut -d: -f2) - - health_response=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port/health" 2>/dev/null) - if [ "$health_response" = "200" ]; then - log_success "$name Service (port $port) is healthy" - else - log_warning "$name Service (port $port) is not responding" - fi -done - -echo "" - -# ================================================================ -# STEP 9: LOGOUT -# ================================================================ - -log_step "Step 9: Logging out user" - -LOGOUT_RESPONSE=$(curl -s -X POST "$AUTH_BASE/logout" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json") - -echo "Logout Response:" -echo "$LOGOUT_RESPONSE" | jq '.' - -if echo "$LOGOUT_RESPONSE" | jq -e '.message' > /dev/null; then - log_success "Logout successful!" -else - log_warning "Logout endpoint may not be fully implemented" -fi - -echo "" - -# ================================================================ -# SUMMARY -# ================================================================ - -echo "🎉 Authentication Test Summary" -echo "===============================" -echo "" -echo "Test User Created:" -echo " 📧 Email: $TEST_EMAIL" -echo " 👤 Name: $TEST_NAME" -echo " 🆔 User ID: $USER_ID" -echo "" -echo "Authentication Flow:" -echo " ✅ User Registration" -echo " ✅ User Login" -echo " ✅ Token Verification" -echo " ✅ Protected Endpoint Access" -echo " ✅ Token Refresh" -echo " ✅ User Logout" -echo "" -echo "Services Tested:" -echo " 🌐 API Gateway" -echo " 🔐 Auth Service" -echo " 🏢 Tenant Service (bakery registration)" -echo " 📊 Data Service (through gateway)" -echo " 🤖 Training Service (through gateway)" -echo "" - -if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then - echo "Tenant Created:" - echo " 🏪 Tenant ID: $TENANT_ID" - echo "" -fi - -log_success "Complete authentication test finished successfully!" -echo "" -echo "🔧 Development Tips:" -echo " • Use the created test user for further development" -echo " • Check service logs with: docker-compose logs [service-name]" -echo " • View API docs at: http://localhost:8000/docs" -echo " • Monitor services at: http://localhost:3002" -echo "" -echo "🧹 Cleanup:" -echo " • Test user will remain in database for development" -echo " • To reset: Delete user from auth database or run cleanup script" \ No newline at end of file diff --git a/services/auth/app/api/users.py b/services/auth/app/api/users.py index 32740533..d512f707 100644 --- a/services/auth/app/api/users.py +++ b/services/auth/app/api/users.py @@ -49,7 +49,9 @@ async def get_current_user_info( ) # ✅ FIX: Fetch full user from database to get the real role - user = await UserService.get_user_by_id(user_id, db) + from app.repositories import UserRepository + user_repo = UserRepository(User, db) + user = await user_repo.get_by_id(user_id) logger.debug(f"Fetched user from DB - Role: {user.role}, Email: {user.email}") @@ -104,7 +106,21 @@ async def update_current_user( """Update current user information""" try: user_id = current_user.get("user_id") if isinstance(current_user, dict) else current_user.id - updated_user = await UserService.update_user(user_id, user_update, db) + from app.repositories import UserRepository + user_repo = UserRepository(User, db) + + # Prepare update data + update_data = {} + if user_update.full_name is not None: + update_data["full_name"] = user_update.full_name + if user_update.phone is not None: + update_data["phone"] = user_update.phone + if user_update.language is not None: + update_data["language"] = user_update.language + if user_update.timezone is not None: + update_data["timezone"] = user_update.timezone + + updated_user = await user_repo.update(user_id, update_data) return UserResponse( id=str(updated_user.id), email=updated_user.email, diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index c7b0fd01..016980e0 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -259,7 +259,7 @@ class EnhancedAuthService: """Logout user using repository pattern""" try: async with self.database_manager.get_session() as session: - token_repo = TokenRepository(session) + token_repo = TokenRepository(RefreshToken, session) # Revoke specific refresh token using repository success = await token_repo.revoke_token(user_id, refresh_token) @@ -291,8 +291,8 @@ class EnhancedAuthService: ) async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) - token_repo = TokenRepository(session) + user_repo = UserRepository(User, session) + token_repo = TokenRepository(RefreshToken, session) # Validate refresh token using repository is_valid = await token_repo.validate_refresh_token(refresh_token, user_id) @@ -364,7 +364,7 @@ class EnhancedAuthService: """Get user profile using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) user = await user_repo.get_by_id(user_id) if not user: @@ -394,7 +394,7 @@ class EnhancedAuthService: """Update user profile using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) updated_user = await user_repo.update(user_id, update_data) if not updated_user: @@ -429,8 +429,8 @@ class EnhancedAuthService: """Change user password using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) - token_repo = TokenRepository(session) + user_repo = UserRepository(User, session) + token_repo = TokenRepository(RefreshToken, session) # Get user and verify old password user = await user_repo.get_by_id(user_id) @@ -470,7 +470,7 @@ class EnhancedAuthService: """Verify user email using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) # In a real implementation, you'd verify the verification_token # For now, just mark user as verified @@ -493,8 +493,8 @@ class EnhancedAuthService: """Deactivate user account using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) - token_repo = TokenRepository(session) + user_repo = UserRepository(User, session) + token_repo = TokenRepository(RefreshToken, session) # Update user status updated_user = await user_repo.update(user_id, {"is_active": False}) diff --git a/services/auth/app/services/user_service.py b/services/auth/app/services/user_service.py index e1d9b9c7..09c495d3 100644 --- a/services/auth/app/services/user_service.py +++ b/services/auth/app/services/user_service.py @@ -10,6 +10,7 @@ import structlog from app.repositories import UserRepository, TokenRepository from app.schemas.auth import UserResponse, UserUpdate +from app.models.users import User, RefreshToken from app.core.security import SecurityManager from shared.database.unit_of_work import UnitOfWork from shared.database.transactions import transactional @@ -29,7 +30,7 @@ class EnhancedUserService: """Get user by ID using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) user = await user_repo.get_by_id(user_id) if not user: @@ -58,7 +59,7 @@ class EnhancedUserService: """Get user by email using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) user = await user_repo.get_by_email(email) if not user: @@ -93,7 +94,7 @@ class EnhancedUserService: """Get paginated list of users using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) filters = {} if active_only: @@ -139,7 +140,7 @@ class EnhancedUserService: """Update user information using repository pattern""" try: async with self.database_manager.get_session() as db_session: - user_repo = UserRepository(db_session) + user_repo = UserRepository(User, db_session) # Validate user exists existing_user = await user_repo.get_by_id(user_id) @@ -298,7 +299,7 @@ class EnhancedUserService: """Activate user account using repository pattern""" try: async with self.database_manager.get_session() as db_session: - user_repo = UserRepository(db_session) + user_repo = UserRepository(User, db_session) # Update user status updated_user = await user_repo.update(user_id, {"is_active": True}) @@ -321,7 +322,7 @@ class EnhancedUserService: """Verify user email using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) # In a real implementation, you'd verify the verification_token # For now, just mark user as verified @@ -344,7 +345,7 @@ class EnhancedUserService: """Get user statistics using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) # Get basic user statistics statistics = await user_repo.get_user_statistics() @@ -372,7 +373,7 @@ class EnhancedUserService: """Search users by email or name using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) users = await user_repo.search_users( search_term, role, active_only, skip, limit @@ -409,7 +410,7 @@ class EnhancedUserService: """Update user role using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) + user_repo = UserRepository(User, session) # Validate role valid_roles = ["user", "admin", "super_admin"] @@ -452,8 +453,8 @@ class EnhancedUserService: """Get user activity information using repository pattern""" try: async with self.database_manager.get_session() as session: - user_repo = UserRepository(session) - token_repo = TokenRepository(session) + user_repo = UserRepository(User, session) + token_repo = TokenRepository(RefreshToken, session) # Get user user = await user_repo.get_by_id(user_id) diff --git a/services/forecasting/app/repositories/base.py b/services/forecasting/app/repositories/base.py index 9937f3d5..49c5aabc 100644 --- a/services/forecasting/app/repositories/base.py +++ b/services/forecasting/app/repositories/base.py @@ -197,7 +197,7 @@ class ForecastingBaseRepository(BaseRepository): errors = [] for field in required_fields: - if field not in data or not data[field]: + if field not in data or data[field] is None: errors.append(f"Missing required field: {field}") # Validate tenant_id format if present diff --git a/test_all_services.py b/test_all_services.py deleted file mode 100644 index 6b6fa211..00000000 --- a/test_all_services.py +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive test to verify all services can start correctly after database refactoring -""" - -import subprocess -import time -import sys -from typing import Dict, List, Tuple - -# Service configurations -SERVICES = { - "infrastructure": { - "services": ["redis", "rabbitmq", "auth-db", "data-db", "tenant-db", - "forecasting-db", "notification-db", "training-db", "prometheus"], - "wait_time": 60 - }, - "core": { - "services": ["auth-service", "data-service"], - "wait_time": 45, - "health_checks": [ - ("auth-service", "http://localhost:8001/health"), - ("data-service", "http://localhost:8002/health"), - ] - }, - "business": { - "services": ["tenant-service", "training-service", "forecasting-service", - "notification-service"], - "wait_time": 45, - "health_checks": [ - ("tenant-service", "http://localhost:8003/health"), - ("training-service", "http://localhost:8004/health"), - ("forecasting-service", "http://localhost:8005/health"), - ("notification-service", "http://localhost:8006/health"), - ] - }, - "ui": { - "services": ["gateway", "dashboard"], - "wait_time": 30, - "health_checks": [ - ("gateway", "http://localhost:8000/health"), - ] - } -} - -def run_command(command: str, description: str = None) -> Tuple[int, str]: - """Run a shell command and return exit code and output""" - if description: - print(f"Running: {command}") - - try: - result = subprocess.run( - command, - shell=True, - capture_output=True, - text=True, - timeout=120 - ) - return result.returncode, result.stdout + result.stderr - except subprocess.TimeoutExpired: - return -1, f"Command timed out: {command}" - -def check_container_status() -> Dict[str, str]: - """Get status of all containers""" - exit_code, output = run_command("docker compose ps") - - if exit_code != 0: - return {} - - status_dict = {} - lines = output.strip().split('\n')[1:] # Skip header - - for line in lines: - if line.strip(): - parts = line.split() - if len(parts) >= 4: - name = parts[0].replace('bakery-', '') - status = ' '.join(parts[3:]) - status_dict[name] = status - - return status_dict - -def check_health(service: str, url: str) -> bool: - """Check if a service health endpoint is responding""" - try: - # Use curl for health checks instead of requests - exit_code, _ = run_command(f"curl -f {url}", None) - return exit_code == 0 - except Exception as e: - print(f"❌ Health check failed for {service}: {str(e)}") - return False - -def wait_for_healthy_containers(services: List[str], max_wait: int = 60) -> bool: - """Wait for containers to become healthy""" - print(f"⏳ Waiting up to {max_wait} seconds for services to become healthy...") - - for i in range(max_wait): - status = check_container_status() - healthy_count = 0 - - for service in services: - service_status = status.get(service, "not found") - if "healthy" in service_status.lower() or "up" in service_status.lower(): - healthy_count += 1 - - if healthy_count == len(services): - print(f"✅ All {len(services)} services are healthy after {i+1} seconds") - return True - - time.sleep(1) - - print(f"⚠️ Only {healthy_count}/{len(services)} services became healthy") - return False - -def test_service_group(group_name: str, config: Dict) -> bool: - """Test a group of services""" - print(f"\n🧪 Testing {group_name} services...") - print(f"Services: {', '.join(config['services'])}") - - # Start services - services_str = ' '.join(config['services']) - exit_code, output = run_command(f"docker compose up -d {services_str}") - - if exit_code != 0: - print(f"❌ Failed to start {group_name} services") - print(output[-1000:]) # Last 1000 chars of output - return False - - print(f"✅ {group_name.title()} services started") - - # Wait for services to be healthy - if not wait_for_healthy_containers(config['services'], config['wait_time']): - print(f"⚠️ Some {group_name} services didn't become healthy") - - # Show container status - status = check_container_status() - for service in config['services']: - service_status = status.get(service, "not found") - print(f" {service}: {service_status}") - - # Show logs for failed services - for service in config['services']: - service_status = status.get(service, "") - if "unhealthy" in service_status.lower() or "restarting" in service_status.lower(): - print(f"\n📋 Logs for failed service {service}:") - _, logs = run_command(f"docker compose logs --tail=10 {service}") - print(logs[-800:]) # Last 800 chars - - return False - - # Run health checks if defined - if "health_checks" in config: - print(f"🔍 Running health checks for {group_name} services...") - - for service, url in config['health_checks']: - if check_health(service, url): - print(f"✅ {service} health check passed") - else: - print(f"❌ {service} health check failed") - return False - - print(f"🎉 {group_name.title()} services test PASSED!") - return True - -def main(): - """Main test function""" - print("🔧 COMPREHENSIVE SERVICES STARTUP TEST") - print("=" * 50) - - # Clean up any existing containers - print("🧹 Cleaning up existing containers...") - run_command("docker compose down") - - all_passed = True - - # Test each service group in order - for group_name, config in SERVICES.items(): - if not test_service_group(group_name, config): - all_passed = False - break - - # Final status check - print("\n📊 Final container status:") - status = check_container_status() - - healthy_count = 0 - total_count = 0 - - for group_config in SERVICES.values(): - for service in group_config['services']: - total_count += 1 - service_status = status.get(service, "not found") - status_icon = "✅" if ("healthy" in service_status.lower() or "up" in service_status.lower()) else "❌" - - if "healthy" in service_status.lower() or "up" in service_status.lower(): - healthy_count += 1 - - print(f"{status_icon} {service}: {service_status}") - - # Clean up - print("\n🧹 Cleaning up containers...") - run_command("docker compose down") - - # Final result - print("=" * 50) - if all_passed and healthy_count == total_count: - print(f"🎉 ALL SERVICES TEST PASSED!") - print(f"✅ {healthy_count}/{total_count} services started successfully") - print("💡 Your docker-compose setup is working correctly") - print("🚀 You can now run: docker compose up -d") - return 0 - else: - print(f"❌ SERVICES TEST FAILED") - print(f"⚠️ {healthy_count}/{total_count} services started successfully") - print("💡 Check the logs above for details") - return 1 - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/test_docker_build.py b/test_docker_build.py deleted file mode 100644 index fb0aecb7..00000000 --- a/test_docker_build.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -""" -Docker Build and Compose Test Script -Tests that each service can be built correctly and docker-compose starts without errors -""" - -import os -import sys -import subprocess -import time -import json -from pathlib import Path - -def run_command(cmd, cwd=None, timeout=300, capture_output=True): - """Run a shell command with timeout and error handling""" - try: - print(f"Running: {cmd}") - if capture_output: - result = subprocess.run( - cmd, - shell=True, - cwd=cwd, - timeout=timeout, - capture_output=True, - text=True - ) - else: - result = subprocess.run( - cmd, - shell=True, - cwd=cwd, - timeout=timeout - ) - return result - except subprocess.TimeoutExpired: - print(f"Command timed out after {timeout} seconds: {cmd}") - return None - except Exception as e: - print(f"Error running command: {e}") - return None - -def check_docker_available(): - """Check if Docker is available and running""" - print("🐳 Checking Docker availability...") - - # Check if docker command exists - result = run_command("which docker") - if result.returncode != 0: - print("❌ Docker command not found. Please install Docker.") - return False - - # Check if Docker daemon is running - result = run_command("docker version") - if result.returncode != 0: - print("❌ Docker daemon is not running. Please start Docker.") - return False - - # Check if docker-compose is available - result = run_command("docker compose version") - if result.returncode != 0: - # Try legacy docker-compose - result = run_command("docker-compose version") - if result.returncode != 0: - print("❌ docker-compose not found. Please install docker-compose.") - return False - else: - print("✅ Using legacy docker-compose") - else: - print("✅ Using Docker Compose v2") - - print("✅ Docker is available and running") - return True - -def test_individual_builds(): - """Test building each service individually""" - print("\n🔨 Testing individual service builds...") - - services = [ - "auth-service", - "tenant-service", - "training-service", - "forecasting-service", - "data-service", - "notification-service" - ] - - build_results = {} - - for service in services: - print(f"\n--- Building {service} ---") - - dockerfile_path = f"./services/{service.replace('-service', '')}/Dockerfile" - if not os.path.exists(dockerfile_path): - print(f"❌ Dockerfile not found: {dockerfile_path}") - build_results[service] = False - continue - - # Build the service - cmd = f"docker build -t bakery/{service}:test -f {dockerfile_path} ." - result = run_command(cmd, timeout=600) - - if result and result.returncode == 0: - print(f"✅ {service} built successfully") - build_results[service] = True - else: - print(f"❌ {service} build failed") - if result and result.stderr: - print(f"Error: {result.stderr}") - build_results[service] = False - - return build_results - -def test_docker_compose_config(): - """Test docker-compose configuration""" - print("\n📋 Testing docker-compose configuration...") - - # Check if docker-compose.yml exists - if not os.path.exists("docker-compose.yml"): - print("❌ docker-compose.yml not found") - return False - - # Check if .env file exists - if not os.path.exists(".env"): - print("❌ .env file not found") - return False - - # Validate docker-compose configuration - result = run_command("docker compose config") - if result.returncode != 0: - # Try legacy docker-compose - result = run_command("docker-compose config") - if result.returncode != 0: - print("❌ docker-compose configuration validation failed") - if result.stderr: - print(f"Error: {result.stderr}") - return False - - print("✅ docker-compose configuration is valid") - return True - -def test_essential_services_startup(): - """Test starting essential infrastructure services only""" - print("\n🚀 Testing essential services startup...") - - # Start only databases and infrastructure - not the application services - essential_services = [ - "redis", - "rabbitmq", - "auth-db", - "data-db" - ] - - try: - # Stop any running containers first - print("Stopping any existing containers...") - run_command("docker compose down", timeout=120) - - # Start essential services - services_str = " ".join(essential_services) - cmd = f"docker compose up -d {services_str}" - result = run_command(cmd, timeout=300) - - if result.returncode != 0: - print("❌ Failed to start essential services") - if result.stderr: - print(f"Error: {result.stderr}") - return False - - # Wait for services to be ready - print("Waiting for services to be ready...") - time.sleep(30) - - # Check service health - print("Checking service health...") - result = run_command("docker compose ps") - if result.returncode == 0: - print("Services status:") - print(result.stdout) - - print("✅ Essential services started successfully") - return True - - except Exception as e: - print(f"❌ Error during essential services test: {e}") - return False - finally: - # Cleanup - print("Cleaning up essential services test...") - run_command("docker compose down", timeout=120) - -def cleanup_docker_resources(): - """Clean up Docker resources""" - print("\n🧹 Cleaning up Docker resources...") - - # Remove test images - services = ["auth-service", "tenant-service", "training-service", - "forecasting-service", "data-service", "notification-service"] - - for service in services: - run_command(f"docker rmi bakery/{service}:test", timeout=30) - - # Remove dangling images - run_command("docker image prune -f", timeout=60) - - print("✅ Docker resources cleaned up") - -def main(): - """Main test function""" - print("🔍 DOCKER BUILD AND COMPOSE TESTING") - print("=" * 50) - - base_path = Path(__file__).parent - os.chdir(base_path) - - # Test results - test_results = { - "docker_available": False, - "compose_config": False, - "individual_builds": {}, - "essential_services": False - } - - # Check Docker availability - test_results["docker_available"] = check_docker_available() - if not test_results["docker_available"]: - print("\n❌ Docker tests cannot proceed without Docker") - return 1 - - # Test docker-compose configuration - test_results["compose_config"] = test_docker_compose_config() - - # Test individual builds (this might take a while) - print("\n⚠️ Individual builds test can take several minutes...") - user_input = input("Run individual service builds? (y/N): ").lower() - - if user_input == 'y': - test_results["individual_builds"] = test_individual_builds() - else: - print("Skipping individual builds test") - test_results["individual_builds"] = {"skipped": True} - - # Test essential services startup - print("\n⚠️ Essential services test will start Docker containers...") - user_input = input("Test essential services startup? (y/N): ").lower() - - if user_input == 'y': - test_results["essential_services"] = test_essential_services_startup() - else: - print("Skipping essential services test") - test_results["essential_services"] = "skipped" - - # Print final results - print("\n" + "=" * 50) - print("📋 TEST RESULTS SUMMARY") - print("=" * 50) - - print(f"Docker Available: {'✅' if test_results['docker_available'] else '❌'}") - print(f"Docker Compose Config: {'✅' if test_results['compose_config'] else '❌'}") - - if "skipped" in test_results["individual_builds"]: - print("Individual Builds: ⏭️ Skipped") - else: - builds = test_results["individual_builds"] - success_count = sum(1 for v in builds.values() if v) - total_count = len(builds) - print(f"Individual Builds: {success_count}/{total_count} ✅") - - for service, success in builds.items(): - status = "✅" if success else "❌" - print(f" - {service}: {status}") - - if test_results["essential_services"] == "skipped": - print("Essential Services: ⏭️ Skipped") - else: - print(f"Essential Services: {'✅' if test_results['essential_services'] else '❌'}") - - # Cleanup - if user_input == 'y' and "skipped" not in test_results["individual_builds"]: - cleanup_docker_resources() - - # Determine overall success - if (test_results["docker_available"] and - test_results["compose_config"] and - (test_results["individual_builds"] == {"skipped": True} or - all(test_results["individual_builds"].values())) and - (test_results["essential_services"] == "skipped" or test_results["essential_services"])): - print("\n🎉 ALL TESTS PASSED") - return 0 - else: - print("\n❌ SOME TESTS FAILED") - return 1 - -if __name__ == "__main__": - exit_code = main() - sys.exit(exit_code) \ No newline at end of file diff --git a/test_docker_build_auto.py b/test_docker_build_auto.py deleted file mode 100644 index 62a4bfbf..00000000 --- a/test_docker_build_auto.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python3 -""" -Automated Docker Build and Compose Test Script -Tests that each service can be built correctly and docker-compose starts without errors -""" - -import os -import sys -import subprocess -import time -import json -from pathlib import Path - -def run_command(cmd, cwd=None, timeout=300, capture_output=True): - """Run a shell command with timeout and error handling""" - try: - print(f"Running: {cmd}") - if capture_output: - result = subprocess.run( - cmd, - shell=True, - cwd=cwd, - timeout=timeout, - capture_output=True, - text=True - ) - else: - result = subprocess.run( - cmd, - shell=True, - cwd=cwd, - timeout=timeout - ) - return result - except subprocess.TimeoutExpired: - print(f"Command timed out after {timeout} seconds: {cmd}") - return None - except Exception as e: - print(f"Error running command: {e}") - return None - -def check_docker_available(): - """Check if Docker is available and running""" - print("🐳 Checking Docker availability...") - - # Check if docker command exists - result = run_command("which docker") - if result.returncode != 0: - print("❌ Docker command not found. Please install Docker.") - return False - - # Check if Docker daemon is running - result = run_command("docker version") - if result.returncode != 0: - print("❌ Docker daemon is not running. Please start Docker.") - return False - - # Check if docker-compose is available - result = run_command("docker compose version") - if result.returncode != 0: - # Try legacy docker-compose - result = run_command("docker-compose version") - if result.returncode != 0: - print("❌ docker-compose not found. Please install docker-compose.") - return False - else: - print("✅ Using legacy docker-compose") - else: - print("✅ Using Docker Compose v2") - - print("✅ Docker is available and running") - return True - -def test_docker_compose_config(): - """Test docker-compose configuration""" - print("\n📋 Testing docker-compose configuration...") - - # Check if docker-compose.yml exists - if not os.path.exists("docker-compose.yml"): - print("❌ docker-compose.yml not found") - return False - - # Check if .env file exists - if not os.path.exists(".env"): - print("❌ .env file not found") - return False - - # Validate docker-compose configuration - result = run_command("docker compose config") - if result.returncode != 0: - # Try legacy docker-compose - result = run_command("docker-compose config") - if result.returncode != 0: - print("❌ docker-compose configuration validation failed") - if result.stderr: - print(f"Error: {result.stderr}") - return False - - print("✅ docker-compose configuration is valid") - return True - -def test_dockerfile_syntax(): - """Test that each Dockerfile has valid syntax""" - print("\n📄 Testing Dockerfile syntax...") - - services = [ - ("auth", "services/auth/Dockerfile"), - ("tenant", "services/tenant/Dockerfile"), - ("training", "services/training/Dockerfile"), - ("forecasting", "services/forecasting/Dockerfile"), - ("data", "services/data/Dockerfile"), - ("notification", "services/notification/Dockerfile") - ] - - dockerfile_results = {} - - for service_name, dockerfile_path in services: - print(f"\n--- Checking {service_name} Dockerfile ---") - - if not os.path.exists(dockerfile_path): - print(f"❌ Dockerfile not found: {dockerfile_path}") - dockerfile_results[service_name] = False - continue - - # Check Dockerfile syntax using docker build --dry-run (if available) - # Otherwise just check if file exists and is readable - try: - with open(dockerfile_path, 'r') as f: - content = f.read() - if 'FROM' not in content: - print(f"❌ {service_name} Dockerfile missing FROM instruction") - dockerfile_results[service_name] = False - elif 'WORKDIR' not in content: - print(f"⚠️ {service_name} Dockerfile missing WORKDIR instruction") - dockerfile_results[service_name] = True - else: - print(f"✅ {service_name} Dockerfile syntax looks good") - dockerfile_results[service_name] = True - except Exception as e: - print(f"❌ Error reading {service_name} Dockerfile: {e}") - dockerfile_results[service_name] = False - - return dockerfile_results - -def check_requirements_files(): - """Check that requirements.txt files exist for each service""" - print("\n📦 Checking requirements.txt files...") - - services = ["auth", "tenant", "training", "forecasting", "data", "notification"] - requirements_results = {} - - for service in services: - req_path = f"services/{service}/requirements.txt" - if os.path.exists(req_path): - print(f"✅ {service} requirements.txt found") - requirements_results[service] = True - else: - print(f"❌ {service} requirements.txt missing") - requirements_results[service] = False - - return requirements_results - -def test_service_main_files(): - """Check that each service has a main.py file""" - print("\n🐍 Checking service main.py files...") - - services = ["auth", "tenant", "training", "forecasting", "data", "notification"] - main_file_results = {} - - for service in services: - main_path = f"services/{service}/app/main.py" - if os.path.exists(main_path): - try: - with open(main_path, 'r') as f: - content = f.read() - if 'FastAPI' in content or 'app = ' in content: - print(f"✅ {service} main.py looks good") - main_file_results[service] = True - else: - print(f"⚠️ {service} main.py exists but may not be a FastAPI app") - main_file_results[service] = True - except Exception as e: - print(f"❌ Error reading {service} main.py: {e}") - main_file_results[service] = False - else: - print(f"❌ {service} main.py missing") - main_file_results[service] = False - - return main_file_results - -def quick_build_test(): - """Quick test to see if one service can build (faster than full build)""" - print("\n⚡ Quick build test (data service only)...") - - # Test building just the data service - cmd = "docker build --no-cache -t bakery/data-service:quick-test -f services/data/Dockerfile ." - result = run_command(cmd, timeout=600) - - if result and result.returncode == 0: - print("✅ Data service quick build successful") - # Cleanup - run_command("docker rmi bakery/data-service:quick-test", timeout=30) - return True - else: - print("❌ Data service quick build failed") - if result and result.stderr: - print(f"Build error: {result.stderr[-1000:]}") # Last 1000 chars - return False - -def main(): - """Main test function""" - print("🔍 AUTOMATED DOCKER BUILD AND COMPOSE TESTING") - print("=" * 60) - - base_path = Path(__file__).parent - os.chdir(base_path) - - # Test results - test_results = { - "docker_available": False, - "compose_config": False, - "dockerfile_syntax": {}, - "requirements_files": {}, - "main_files": {}, - "quick_build": False - } - - # Check Docker availability - test_results["docker_available"] = check_docker_available() - if not test_results["docker_available"]: - print("\n❌ Docker tests cannot proceed without Docker") - return 1 - - # Test docker-compose configuration - test_results["compose_config"] = test_docker_compose_config() - - # Test Dockerfile syntax - test_results["dockerfile_syntax"] = test_dockerfile_syntax() - - # Check requirements files - test_results["requirements_files"] = check_requirements_files() - - # Check main.py files - test_results["main_files"] = test_service_main_files() - - # Quick build test - test_results["quick_build"] = quick_build_test() - - # Print final results - print("\n" + "=" * 60) - print("📋 TEST RESULTS SUMMARY") - print("=" * 60) - - print(f"Docker Available: {'✅' if test_results['docker_available'] else '❌'}") - print(f"Docker Compose Config: {'✅' if test_results['compose_config'] else '❌'}") - - # Dockerfile syntax results - dockerfile_success = all(test_results["dockerfile_syntax"].values()) - print(f"Dockerfile Syntax: {'✅' if dockerfile_success else '❌'}") - for service, success in test_results["dockerfile_syntax"].items(): - status = "✅" if success else "❌" - print(f" - {service}: {status}") - - # Requirements files results - req_success = all(test_results["requirements_files"].values()) - print(f"Requirements Files: {'✅' if req_success else '❌'}") - for service, success in test_results["requirements_files"].items(): - status = "✅" if success else "❌" - print(f" - {service}: {status}") - - # Main files results - main_success = all(test_results["main_files"].values()) - print(f"Main.py Files: {'✅' if main_success else '❌'}") - for service, success in test_results["main_files"].items(): - status = "✅" if success else "❌" - print(f" - {service}: {status}") - - print(f"Quick Build Test: {'✅' if test_results['quick_build'] else '❌'}") - - # Determine overall success - overall_success = ( - test_results["docker_available"] and - test_results["compose_config"] and - dockerfile_success and - req_success and - main_success and - test_results["quick_build"] - ) - - if overall_success: - print("\n🎉 ALL TESTS PASSED - Services should build and start correctly!") - print("💡 You can now run: docker compose up -d") - return 0 - else: - print("\n❌ SOME TESTS FAILED - Please fix issues before running docker compose") - return 1 - -if __name__ == "__main__": - exit_code = main() - sys.exit(exit_code) \ No newline at end of file diff --git a/test_docker_simple.py b/test_docker_simple.py deleted file mode 100644 index bfa08b73..00000000 --- a/test_docker_simple.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple Docker Services Test -Tests that docker-compose can start services without external dependencies -""" - -import os -import sys -import subprocess -import time -from pathlib import Path - -def run_command(cmd, timeout=300): - """Run a shell command with timeout""" - try: - print(f"Running: {cmd}") - result = subprocess.run( - cmd, - shell=True, - timeout=timeout, - capture_output=True, - text=True - ) - return result - except subprocess.TimeoutExpired: - print(f"Command timed out after {timeout} seconds") - return None - except Exception as e: - print(f"Error running command: {e}") - return None - -def test_infrastructure_services(): - """Test starting just infrastructure services""" - print("🏗️ Testing infrastructure services...") - - try: - # Stop any existing containers - print("Cleaning up existing containers...") - run_command("docker compose down", timeout=120) - - # Start only infrastructure services - infra_services = "redis rabbitmq auth-db data-db" - cmd = f"docker compose up -d {infra_services}" - result = run_command(cmd, timeout=300) - - if result and result.returncode == 0: - print("✅ Infrastructure services started") - - # Wait a bit for services to initialize - print("Waiting 30 seconds for services to initialize...") - time.sleep(30) - - # Check container status - status_result = run_command("docker compose ps", timeout=30) - if status_result and status_result.stdout: - print("Container status:") - print(status_result.stdout) - - # Try to start one application service - print("Testing application service startup...") - app_result = run_command("docker compose up -d auth-service", timeout=180) - - if app_result and app_result.returncode == 0: - print("✅ Auth service started successfully") - - # Wait for it to initialize - time.sleep(20) - - # Check health with curl - health_result = run_command("curl -f http://localhost:8001/health", timeout=10) - if health_result and health_result.returncode == 0: - print("✅ Auth service is healthy!") - return True - else: - print("⚠️ Auth service started but health check failed") - # Show logs for debugging - logs_result = run_command("docker compose logs --tail=20 auth-service", timeout=30) - if logs_result and logs_result.stdout: - print("Auth service logs:") - print(logs_result.stdout) - return False - else: - print("❌ Failed to start auth service") - if app_result and app_result.stderr: - print(f"Error: {app_result.stderr}") - return False - else: - print("❌ Failed to start infrastructure services") - if result and result.stderr: - print(f"Error: {result.stderr}") - return False - - except Exception as e: - print(f"❌ Error during infrastructure test: {e}") - return False - -def show_final_status(): - """Show final container status""" - print("\n📊 Final container status:") - result = run_command("docker compose ps", timeout=30) - if result and result.stdout: - print(result.stdout) - -def cleanup(): - """Clean up containers""" - print("\n🧹 Cleaning up containers...") - run_command("docker compose down", timeout=180) - print("✅ Cleanup completed") - -def main(): - """Main test function""" - print("🔧 SIMPLE DOCKER SERVICES TEST") - print("=" * 40) - - base_path = Path(__file__).parent - os.chdir(base_path) - - success = False - - try: - # Test infrastructure services - success = test_infrastructure_services() - - # Show current status - show_final_status() - - except KeyboardInterrupt: - print("\n⚠️ Test interrupted by user") - except Exception as e: - print(f"\n❌ Unexpected error: {e}") - finally: - # Always cleanup - cleanup() - - # Final result - print("\n" + "=" * 40) - if success: - print("🎉 DOCKER SERVICES TEST PASSED!") - print("✅ Services can start and respond to health checks") - print("💡 Your docker-compose setup is working correctly") - print("🚀 You can now run: docker compose up -d") - return 0 - else: - print("❌ DOCKER SERVICES TEST FAILED") - print("⚠️ Some issues were found with service startup") - print("💡 Check the logs above for details") - return 1 - -if __name__ == "__main__": - exit_code = main() - sys.exit(exit_code) \ No newline at end of file diff --git a/test_forecasting_fixed.sh b/test_forecasting_fixed.sh deleted file mode 100755 index b4f06836..00000000 --- a/test_forecasting_fixed.sh +++ /dev/null @@ -1,78 +0,0 @@ -#\!/bin/bash - -echo "🧪 Testing Forecasting Service Components - FIXED ROUTES" -echo "======================================================" - -# Use the most recent successful tenant from the full onboarding test -TENANT_ID="5765e61a-4d06-4e17-b614-a4f8410e2a35" - -# Get a fresh access token -echo "1. Getting access token..." -LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"email": "onboarding.test.1754565461@bakery.com", "password": "TestPassword123\!"}') - -ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('access_token', '')) -except: - pass -" 2>/dev/null) - -if [ -z "$ACCESS_TOKEN" ]; then - echo "❌ Login failed" - echo "Response: $LOGIN_RESPONSE" - exit 1 -fi - -echo "✅ Access token obtained" - -# Test 1: Test forecast endpoint with correct path format (no extra path) -echo "" -echo "2. Testing forecast endpoint with correct format..." -FORECAST_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/forecasts" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"product_name": "Cafe", "days_ahead": 7}') - -HTTP_CODE=$(echo "$FORECAST_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -FORECAST_RESPONSE=$(echo "$FORECAST_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Forecast HTTP Code: $HTTP_CODE" -echo "Forecast Response:" -echo "$FORECAST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_RESPONSE" - -# Test 2: Test predictions endpoint with correct format -echo "" -echo "3. Testing predictions endpoint with correct format..." -PREDICTION_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/predictions" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"product_names": ["Cafe", "Pan"], "days_ahead": 5, "include_confidence": true}') - -PRED_HTTP_CODE=$(echo "$PREDICTION_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -PREDICTION_RESPONSE=$(echo "$PREDICTION_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Prediction HTTP Code: $PRED_HTTP_CODE" -echo "Prediction Response:" -echo "$PREDICTION_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PREDICTION_RESPONSE" - -# Test 3: Direct forecasting service test (bypass gateway) -echo "" -echo "4. Testing forecasting service directly (bypass gateway)..." -DIRECT_FORECAST=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8003/api/v1/tenants/$TENANT_ID/forecasts" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"product_name": "Cafe", "days_ahead": 7}') - -DIRECT_HTTP_CODE=$(echo "$DIRECT_FORECAST" | grep "HTTP_CODE:" | cut -d: -f2) -DIRECT_FORECAST=$(echo "$DIRECT_FORECAST" | sed '/HTTP_CODE:/d') - -echo "Direct Forecast HTTP Code: $DIRECT_HTTP_CODE" -echo "Direct Forecast Response:" -echo "$DIRECT_FORECAST" | python3 -m json.tool 2>/dev/null || echo "$DIRECT_FORECAST" - -echo "" -echo "🏁 Fixed forecasting test completed\!" diff --git a/test_forecasting_standalone.sh b/test_forecasting_standalone.sh deleted file mode 100755 index 6681be10..00000000 --- a/test_forecasting_standalone.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/bin/bash - -echo "🧪 Testing Forecasting Service Components" -echo "========================================" - -# Use the most recent successful tenant from the full onboarding test -TENANT_ID="5765e61a-4d06-4e17-b614-a4f8410e2a35" - -# Get a fresh access token -echo "1. Getting access token..." -LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/login" \ - -H "Content-Type: application/json" \ - -d '{"email": "onboarding.test.1754565461@bakery.com", "password": "TestPassword123!"}') - -ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('access_token', '')) -except: - pass -" 2>/dev/null) - -if [ -z "$ACCESS_TOKEN" ]; then - echo "❌ Login failed" - echo "Response: $LOGIN_RESPONSE" - exit 1 -fi - -echo "✅ Access token obtained" - -# Test 1: Check forecasting service health -echo "" -echo "2. Testing forecasting service health..." -FORECAST_HEALTH=$(curl -s "http://localhost:8003/health") -echo "Forecasting Service Health:" -echo "$FORECAST_HEALTH" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_HEALTH" - -# Test 2: Test forecast endpoint (should handle gracefully if no models exist) -echo "" -echo "3. Testing forecast endpoint..." -FORECAST_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/forecasts" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"product_name": "bread", "days_ahead": 7}') - -HTTP_CODE=$(echo "$FORECAST_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -FORECAST_RESPONSE=$(echo "$FORECAST_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Forecast HTTP Code: $HTTP_CODE" -echo "Forecast Response:" -echo "$FORECAST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_RESPONSE" - -# Test 3: Test predictions endpoint -echo "" -echo "4. Testing predictions endpoint..." -PREDICTION_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/predictions" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"product_names": ["bread", "croissant"], "days_ahead": 5, "include_confidence": true}') - -PRED_HTTP_CODE=$(echo "$PREDICTION_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -PREDICTION_RESPONSE=$(echo "$PREDICTION_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Prediction HTTP Code: $PRED_HTTP_CODE" -echo "Prediction Response:" -echo "$PREDICTION_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PREDICTION_RESPONSE" - -# Test 4: Check available products for this tenant -echo "" -echo "5. Testing available products endpoint..." -PRODUCTS_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "http://localhost:8000/api/v1/tenants/$TENANT_ID/sales/products" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - -PROD_HTTP_CODE=$(echo "$PRODUCTS_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -PRODUCTS_RESPONSE=$(echo "$PRODUCTS_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Products HTTP Code: $PROD_HTTP_CODE" -echo "Available Products:" -echo "$PRODUCTS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PRODUCTS_RESPONSE" - -echo "" -echo "🏁 Forecasting service component test completed!" \ No newline at end of file diff --git a/test_frontend_api_simulation.js b/test_frontend_api_simulation.js deleted file mode 100755 index 6f46303f..00000000 --- a/test_frontend_api_simulation.js +++ /dev/null @@ -1,645 +0,0 @@ -#!/usr/bin/env node -/** - * Frontend API Simulation Test - * - * This script simulates how the frontend would interact with the backend APIs - * using the exact same patterns defined in the frontend/src/api structure. - * - * Purpose: - * - Verify frontend API abstraction aligns with backend endpoints - * - Test onboarding flow using frontend API patterns - * - Identify any mismatches between frontend expectations and backend reality - */ - -const https = require('https'); -const http = require('http'); -const { URL } = require('url'); -const fs = require('fs'); -const path = require('path'); - -// Frontend API Configuration (from frontend/src/api/client/config.ts) -const API_CONFIG = { - baseURL: 'http://localhost:8000/api/v1', // Using API Gateway - timeout: 30000, - retries: 3, - retryDelay: 1000, -}; - -// Service Endpoints (from frontend/src/api/client/config.ts) -const SERVICE_ENDPOINTS = { - auth: '/auth', - tenant: '/tenants', - data: '/tenants', // Data operations are tenant-scoped - training: '/tenants', // Training operations are tenant-scoped - forecasting: '/tenants', // Forecasting operations are tenant-scoped - notification: '/tenants', // Notification operations are tenant-scoped -}; - -// Colors for console output -const colors = { - reset: '\x1b[0m', - bright: '\x1b[1m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', -}; - -function log(color, message, ...args) { - console.log(`${colors[color]}${message}${colors.reset}`, ...args); -} - -// HTTP Client Implementation (mimicking frontend apiClient) -class ApiClient { - constructor(baseURL = API_CONFIG.baseURL) { - this.baseURL = baseURL; - this.defaultHeaders = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'Frontend-API-Simulation/1.0', - }; - this.authToken = null; - } - - setAuthToken(token) { - this.authToken = token; - } - - async request(endpoint, options = {}) { - // Properly construct full URL by joining base URL and endpoint - const fullUrl = this.baseURL + endpoint; - const url = new URL(fullUrl); - const isHttps = url.protocol === 'https:'; - const client = isHttps ? https : http; - - const headers = { - ...this.defaultHeaders, - ...options.headers, - }; - - if (this.authToken) { - headers['Authorization'] = `Bearer ${this.authToken}`; - } - - let bodyString = null; - if (options.body) { - bodyString = JSON.stringify(options.body); - headers['Content-Length'] = Buffer.byteLength(bodyString, 'utf8'); - } - - const requestOptions = { - method: options.method || 'GET', - headers, - timeout: options.timeout || API_CONFIG.timeout, - }; - - return new Promise((resolve, reject) => { - const req = client.request(url, requestOptions, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - try { - const parsedData = data ? JSON.parse(data) : {}; - - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(parsedData); - } else { - reject(new Error(`HTTP ${res.statusCode}: ${JSON.stringify(parsedData)}`)); - } - } catch (e) { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(data); - } else { - reject(new Error(`HTTP ${res.statusCode}: ${data}`)); - } - } - }); - }); - - req.on('error', (error) => { - reject(error); - }); - - req.on('timeout', () => { - req.destroy(); - reject(new Error('Request timeout')); - }); - - if (bodyString) { - req.write(bodyString); - } - - req.end(); - }); - } - - async get(endpoint, options = {}) { - const fullUrl = this.baseURL + endpoint; - const url = new URL(fullUrl); - if (options.params) { - Object.entries(options.params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, value); - } - }); - } - return this.request(endpoint + (url.search || ''), { ...options, method: 'GET' }); - } - - async post(endpoint, data, options = {}) { - return this.request(endpoint, { ...options, method: 'POST', body: data }); - } - - async put(endpoint, data, options = {}) { - return this.request(endpoint, { ...options, method: 'PUT', body: data }); - } - - async patch(endpoint, data, options = {}) { - return this.request(endpoint, { ...options, method: 'PATCH', body: data }); - } - - async delete(endpoint, options = {}) { - return this.request(endpoint, { ...options, method: 'DELETE' }); - } -} - -// Frontend Service Implementations -class AuthService { - constructor(apiClient) { - this.apiClient = apiClient; - this.baseEndpoint = SERVICE_ENDPOINTS.auth; - } - - async register(data) { - log('blue', '📋 Frontend AuthService.register() called with:', JSON.stringify(data, null, 2)); - return this.apiClient.post(`${this.baseEndpoint}/register`, data); - } - - async login(credentials) { - log('blue', '🔐 Frontend AuthService.login() called with:', { email: credentials.email, password: '[HIDDEN]' }); - return this.apiClient.post(`${this.baseEndpoint}/login`, credentials); - } - - async getCurrentUser() { - log('blue', '👤 Frontend AuthService.getCurrentUser() called'); - return this.apiClient.get('/users/me'); - } -} - -class TenantService { - constructor(apiClient) { - this.apiClient = apiClient; - this.baseEndpoint = SERVICE_ENDPOINTS.tenant; - } - - async createTenant(data) { - log('blue', '🏪 Frontend TenantService.createTenant() called with:', JSON.stringify(data, null, 2)); - return this.apiClient.post(`${this.baseEndpoint}/register`, data); - } - - async getTenant(tenantId) { - log('blue', `🏪 Frontend TenantService.getTenant(${tenantId}) called`); - return this.apiClient.get(`${this.baseEndpoint}/${tenantId}`); - } -} - -class DataService { - constructor(apiClient) { - this.apiClient = apiClient; - } - - async validateSalesData(tenantId, data, dataFormat = 'csv') { - log('blue', `📊 Frontend DataService.validateSalesData(${tenantId}) called`); - const requestData = { - data: data, - data_format: dataFormat, - validate_only: true, - source: 'onboarding_upload' - }; - return this.apiClient.post(`/tenants/${tenantId}/sales/import/validate-json`, requestData); - } - - async uploadSalesHistory(tenantId, data, additionalData = {}) { - log('blue', `📊 Frontend DataService.uploadSalesHistory(${tenantId}) called`); - - // Create a mock file-like object for upload endpoint - const mockFile = { - name: 'bakery_sales.csv', - size: data.length, - type: 'text/csv' - }; - - const formData = { - file_format: additionalData.file_format || 'csv', - source: additionalData.source || 'onboarding_upload', - ...additionalData - }; - - log('blue', `📊 Making request to /tenants/${tenantId}/sales/import`); - log('blue', `📊 Form data:`, formData); - - // Use the actual import endpoint that the frontend uses - return this.apiClient.post(`/tenants/${tenantId}/sales/import`, { - data: data, - ...formData - }); - } - - async getProductsList(tenantId) { - log('blue', `📦 Frontend DataService.getProductsList(${tenantId}) called`); - return this.apiClient.get(`/tenants/${tenantId}/sales/products`); - } -} - -class TrainingService { - constructor(apiClient) { - this.apiClient = apiClient; - } - - async startTrainingJob(tenantId, request) { - log('blue', `🤖 Frontend TrainingService.startTrainingJob(${tenantId}) called`); - return this.apiClient.post(`/tenants/${tenantId}/training/jobs`, request); - } - - async getTrainingJobStatus(tenantId, jobId) { - log('blue', `🤖 Frontend TrainingService.getTrainingJobStatus(${tenantId}, ${jobId}) called`); - return this.apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/status`); - } -} - -class ForecastingService { - constructor(apiClient) { - this.apiClient = apiClient; - } - - async createForecast(tenantId, request) { - log('blue', `🔮 Frontend ForecastingService.createForecast(${tenantId}) called`); - - // Add location if not present (matching frontend implementation) - const forecastRequest = { - ...request, - location: request.location || "Madrid, Spain" // Default location - }; - - log('blue', `🔮 Forecast request with location:`, forecastRequest); - return this.apiClient.post(`/tenants/${tenantId}/forecasts/single`, forecastRequest); - } -} - -// Main Test Runner -class FrontendApiSimulationTest { - constructor() { - this.apiClient = new ApiClient(); - this.authService = new AuthService(this.apiClient); - this.tenantService = new TenantService(this.apiClient); - this.dataService = new DataService(this.apiClient); - this.trainingService = new TrainingService(this.apiClient); - this.forecastingService = new ForecastingService(this.apiClient); - - this.testResults = { - passed: 0, - failed: 0, - issues: [], - }; - } - - async runTest(name, testFn) { - log('cyan', `\\n🧪 Running: ${name}`); - try { - await testFn(); - this.testResults.passed++; - log('green', `✅ PASSED: ${name}`); - } catch (error) { - this.testResults.failed++; - this.testResults.issues.push({ test: name, error: error.message }); - log('red', `❌ FAILED: ${name}`); - log('red', ` Error: ${error.message}`); - } - } - - async sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - // Load the actual CSV data (same as backend test) - loadCsvData() { - const csvPath = path.join(__dirname, 'bakery_sales_2023_2024.csv'); - try { - const csvContent = fs.readFileSync(csvPath, 'utf8'); - log('green', `✅ Loaded CSV data: ${csvContent.split('\\n').length - 1} records`); - return csvContent; - } catch (error) { - log('yellow', `⚠️ Could not load CSV file, using sample data`); - return 'date,product,quantity,revenue,temperature,precipitation,is_weekend,is_holiday\\n2023-01-01,pan,149,178.8,5.2,0,True,False\\n2023-01-01,croissant,144,216.0,5.2,0,True,False'; - } - } - - async runOnboardingFlowTest() { - log('bright', '🎯 FRONTEND API SIMULATION - ONBOARDING FLOW TEST'); - log('bright', '===================================================='); - - const timestamp = Date.now(); - const testEmail = `frontend.test.${timestamp}@bakery.com`; - const csvData = this.loadCsvData(); - - let userId, tenantId, accessToken, jobId; - - // Step 1: User Registration (Frontend Pattern) - await this.runTest('User Registration', async () => { - log('magenta', '\\n👤 STEP 1: USER REGISTRATION (Frontend Pattern)'); - - // This matches exactly what frontend/src/api/services/auth.service.ts does - const registerData = { - email: testEmail, - password: 'TestPassword123!', - full_name: 'Frontend Test User', - role: 'admin' - }; - - const response = await this.authService.register(registerData); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should have: user.id, access_token, refresh_token, user object'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - // Frontend expects these fields (from frontend/src/api/types/auth.ts) - if (!response.access_token) { - throw new Error('Missing access_token in response'); - } - if (!response.user || !response.user.id) { - throw new Error('Missing user.id in response'); - } - - userId = response.user.id; - accessToken = response.access_token; - this.apiClient.setAuthToken(accessToken); - - log('green', `✅ User ID: ${userId}`); - log('green', `✅ Access Token: ${accessToken.substring(0, 50)}...`); - }); - - // Step 2: Bakery/Tenant Registration (Frontend Pattern) - await this.runTest('Tenant Registration', async () => { - log('magenta', '\\n🏪 STEP 2: TENANT REGISTRATION (Frontend Pattern)'); - - // This matches frontend/src/api/services/tenant.service.ts - const tenantData = { - name: `Frontend Test Bakery ${Math.floor(Math.random() * 1000)}`, - business_type: 'bakery', - address: 'Calle Gran Vía 123', - city: 'Madrid', - postal_code: '28001', - phone: '+34600123456' - }; - - const response = await this.tenantService.createTenant(tenantData); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should have: id, name, owner_id, is_active, created_at'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - // Frontend expects these fields (from frontend/src/api/types/tenant.ts) - if (!response.id) { - throw new Error('Missing id in tenant response'); - } - if (!response.name) { - throw new Error('Missing name in tenant response'); - } - - tenantId = response.id; - log('green', `✅ Tenant ID: ${tenantId}`); - }); - - // Step 3: Sales Data Validation (Frontend Pattern) - await this.runTest('Sales Data Validation', async () => { - log('magenta', '\\n📊 STEP 3: SALES DATA VALIDATION (Frontend Pattern)'); - - // This matches frontend/src/api/services/data.service.ts validateSalesData method - const response = await this.dataService.validateSalesData(tenantId, csvData, 'csv'); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should have: is_valid, total_records, valid_records, errors, warnings'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - // Frontend expects these fields (from frontend/src/api/types/data.ts) - if (typeof response.is_valid !== 'boolean') { - throw new Error('Missing or invalid is_valid field'); - } - if (typeof response.total_records !== 'number') { - throw new Error('Missing or invalid total_records field'); - } - - log('green', `✅ Validation passed: ${response.total_records} records`); - }); - - // Step 4: Sales Data Import (Frontend Pattern) - await this.runTest('Sales Data Import', async () => { - log('magenta', '\\n📊 STEP 4: SALES DATA IMPORT (Frontend Pattern)'); - - // This matches frontend/src/api/services/data.service.ts uploadSalesHistory method - const response = await this.dataService.uploadSalesHistory(tenantId, csvData, { - file_format: 'csv', - source: 'onboarding_upload' - }); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should have: success, records_processed, records_created'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - // Check if this is validation or import response - if (response.is_valid !== undefined) { - log('yellow', '⚠️ API returned validation response instead of import response'); - log('yellow', ' This suggests the import endpoint might not match frontend expectations'); - } - - log('green', `✅ Data processing completed`); - }); - - // Step 5: Training Job Start (Frontend Pattern) - await this.runTest('Training Job Start', async () => { - log('magenta', '\\n🤖 STEP 5: TRAINING JOB START (Frontend Pattern)'); - - // This matches frontend/src/api/services/training.service.ts startTrainingJob method - const trainingRequest = { - location: { - latitude: 40.4168, - longitude: -3.7038 - }, - training_options: { - model_type: 'prophet', - optimization_enabled: true - } - }; - - const response = await this.trainingService.startTrainingJob(tenantId, trainingRequest); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should have: job_id, tenant_id, status, message, training_results'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - // Frontend expects these fields (from frontend/src/api/types/training.ts) - if (!response.job_id) { - throw new Error('Missing job_id in training response'); - } - if (!response.status) { - throw new Error('Missing status in training response'); - } - - jobId = response.job_id; - log('green', `✅ Training Job ID: ${jobId}`); - }); - - // Step 6: Training Status Check (Frontend Pattern) - await this.runTest('Training Status Check', async () => { - log('magenta', '\\n🤖 STEP 6: TRAINING STATUS CHECK (Frontend Pattern)'); - - // Wait longer for background training to initialize and create log record - log('blue', '⏳ Waiting for background training to initialize...'); - await this.sleep(8000); - - // This matches frontend/src/api/services/training.service.ts getTrainingJobStatus method - const response = await this.trainingService.getTrainingJobStatus(tenantId, jobId); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should have: job_id, status, progress, training_results'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - // Frontend expects these fields - if (!response.job_id) { - throw new Error('Missing job_id in status response'); - } - - log('green', `✅ Training Status: ${response.status || 'unknown'}`); - }); - - // Step 7: Product List Check (Frontend Pattern) - await this.runTest('Products List Check', async () => { - log('magenta', '\\n📦 STEP 7: PRODUCTS LIST CHECK (Frontend Pattern)'); - - // Wait a bit for data import to be processed - log('blue', '⏳ Waiting for import processing...'); - await this.sleep(3000); - - // This matches frontend/src/api/services/data.service.ts getProductsList method - const response = await this.dataService.getProductsList(tenantId); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should be: array of product objects with product_name field'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - // Frontend expects array of products - let products = []; - if (Array.isArray(response)) { - products = response; - } else if (response && typeof response === 'object') { - // Handle object response format - products = Object.values(response); - } - - if (products.length === 0) { - throw new Error('No products found in response'); - } - - log('green', `✅ Found ${products.length} products`); - }); - - // Step 8: Forecast Creation Test (Frontend Pattern) - await this.runTest('Forecast Creation Test', async () => { - log('magenta', '\\n🔮 STEP 8: FORECAST CREATION TEST (Frontend Pattern)'); - - // This matches frontend/src/api/services/forecasting.service.ts pattern - const forecastRequest = { - product_name: 'pan', - forecast_date: '2025-08-08', - forecast_days: 7, - location: 'Madrid, Spain', - confidence_level: 0.85 - }; - - try { - const response = await this.forecastingService.createForecast(tenantId, forecastRequest); - - log('blue', 'Expected Frontend Response Structure:'); - log('blue', '- Should have: forecast data with dates, values, confidence intervals'); - log('blue', 'Actual Backend Response:'); - log('blue', JSON.stringify(response, null, 2)); - - log('green', `✅ Forecast created successfully`); - } catch (error) { - if (error.message.includes('500') || error.message.includes('no models')) { - log('yellow', `⚠️ Forecast failed as expected (training may not be complete): ${error.message}`); - // Don't throw - this is expected if training hasn't completed - } else { - throw error; - } - } - }); - - // Results Summary - log('bright', '\\n📊 FRONTEND API SIMULATION TEST RESULTS'); - log('bright', '=========================================='); - log('green', `✅ Passed: ${this.testResults.passed}`); - log('red', `❌ Failed: ${this.testResults.failed}`); - - if (this.testResults.issues.length > 0) { - log('red', '\\n🐛 Issues Found:'); - this.testResults.issues.forEach((issue, index) => { - log('red', `${index + 1}. ${issue.test}: ${issue.error}`); - }); - } - - // API Alignment Analysis - log('bright', '\\n🔍 API ALIGNMENT ANALYSIS'); - log('bright', '==========================='); - - log('blue', '🎯 Frontend-Backend Alignment Summary:'); - log('green', '✅ Auth Service: Registration and login endpoints align well'); - log('green', '✅ Tenant Service: Creation endpoint matches expected structure'); - log('yellow', '⚠️ Data Service: Import vs Validation endpoint confusion detected'); - log('green', '✅ Training Service: Job creation and status endpoints align'); - log('yellow', '⚠️ Forecasting Service: Endpoint structure may need verification'); - - log('blue', '\\n📋 Recommended Frontend API Improvements:'); - log('blue', '1. Add better error handling for different response formats'); - log('blue', '2. Consider adding response transformation layer'); - log('blue', '3. Add validation for expected response fields'); - log('blue', '4. Implement proper timeout handling for long operations'); - log('blue', '5. Add request/response logging for better debugging'); - - const successRate = (this.testResults.passed / (this.testResults.passed + this.testResults.failed)) * 100; - log('bright', `\\n🎉 Overall Success Rate: ${successRate.toFixed(1)}%`); - - if (successRate >= 80) { - log('green', '✅ Frontend API abstraction is well-aligned with backend!'); - } else if (successRate >= 60) { - log('yellow', '⚠️ Frontend API has some alignment issues that should be addressed'); - } else { - log('red', '❌ Significant alignment issues detected - review required'); - } - } -} - -// Run the test -async function main() { - const test = new FrontendApiSimulationTest(); - await test.runOnboardingFlowTest(); -} - -if (require.main === module) { - main().catch(console.error); -} - -module.exports = { FrontendApiSimulationTest }; \ No newline at end of file diff --git a/test_services_startup.py b/test_services_startup.py deleted file mode 100644 index 64fd3a48..00000000 --- a/test_services_startup.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -""" -Services Startup Test Script -Tests that services actually start and respond to health checks -""" - -import os -import sys -import subprocess -import time -import requests -from pathlib import Path - -def run_command(cmd, cwd=None, timeout=300): - """Run a shell command with timeout""" - try: - print(f"Running: {cmd}") - result = subprocess.run( - cmd, - shell=True, - cwd=cwd, - timeout=timeout, - capture_output=True, - text=True - ) - return result - except subprocess.TimeoutExpired: - print(f"Command timed out after {timeout} seconds: {cmd}") - return None - except Exception as e: - print(f"Error running command: {e}") - return None - -def wait_for_service(url, max_attempts=30, delay=10): - """Wait for a service to become healthy""" - print(f"Waiting for service at {url}...") - - for attempt in range(max_attempts): - try: - response = requests.get(url, timeout=5) - if response.status_code == 200: - print(f"✅ Service at {url} is healthy") - return True - except requests.exceptions.RequestException: - pass - - if attempt < max_attempts - 1: - print(f"Attempt {attempt + 1}/{max_attempts} failed, waiting {delay}s...") - time.sleep(delay) - - print(f"❌ Service at {url} did not become healthy") - return False - -def test_essential_services(): - """Test starting essential services and one application service""" - print("🚀 Testing essential services startup...") - - try: - # Stop any running containers - print("Stopping any existing containers...") - run_command("docker compose down", timeout=120) - - # Start infrastructure services first - print("Starting infrastructure services...") - infra_cmd = "docker compose up -d redis rabbitmq auth-db data-db" - result = run_command(infra_cmd, timeout=300) - - if result.returncode != 0: - print("❌ Failed to start infrastructure services") - if result.stderr: - print(f"Error: {result.stderr}") - return False - - # Wait for infrastructure to be ready - print("Waiting for infrastructure services...") - time.sleep(30) - - # Start one application service (auth-service) to test - print("Starting auth service...") - app_cmd = "docker compose up -d auth-service" - result = run_command(app_cmd, timeout=300) - - if result.returncode != 0: - print("❌ Failed to start auth service") - if result.stderr: - print(f"Error: {result.stderr}") - return False - - # Wait for auth service to be ready - print("Waiting for auth service to start...") - time.sleep(45) # Give more time for app service - - # Check if auth service is healthy - auth_healthy = wait_for_service("http://localhost:8001/health", max_attempts=10, delay=5) - - if not auth_healthy: - # Show logs to debug - print("Showing auth service logs...") - logs_result = run_command("docker compose logs auth-service", timeout=30) - if logs_result and logs_result.stdout: - print("Auth service logs:") - print(logs_result.stdout[-2000:]) # Last 2000 chars - - return auth_healthy - - except Exception as e: - print(f"❌ Error during services test: {e}") - return False - -def show_service_status(): - """Show the status of all containers""" - print("\n📊 Current container status:") - result = run_command("docker compose ps", timeout=30) - if result and result.stdout: - print(result.stdout) - else: - print("Could not get container status") - -def test_docker_compose_basic(): - """Test basic docker-compose functionality""" - print("🐳 Testing basic docker-compose functionality...") - - try: - # Test docker-compose up --dry-run if available - result = run_command("docker compose config --services", timeout=30) - - if result.returncode == 0: - services = result.stdout.strip().split('\n') - print(f"✅ Found {len(services)} services in docker-compose.yml:") - for service in services: - print(f" - {service}") - return True - else: - print("❌ Could not list services from docker-compose.yml") - return False - - except Exception as e: - print(f"❌ Error testing docker-compose: {e}") - return False - -def cleanup(): - """Clean up all containers and resources""" - print("\n🧹 Cleaning up...") - - # Stop all containers - run_command("docker compose down", timeout=180) - - # Remove unused images (only test images) - run_command("docker image prune -f", timeout=60) - - print("✅ Cleanup completed") - -def main(): - """Main test function""" - print("🧪 SERVICES STARTUP TEST") - print("=" * 40) - - base_path = Path(__file__).parent - os.chdir(base_path) - - success = True - - try: - # Test basic docker-compose functionality - if not test_docker_compose_basic(): - success = False - - # Test essential services startup - if not test_essential_services(): - success = False - - # Show final status - show_service_status() - - except KeyboardInterrupt: - print("\n⚠️ Test interrupted by user") - success = False - except Exception as e: - print(f"\n❌ Unexpected error: {e}") - success = False - finally: - # Always cleanup - cleanup() - - # Final result - print("\n" + "=" * 40) - if success: - print("🎉 SERVICES STARTUP TEST PASSED!") - print("✅ Services can build and start successfully") - print("💡 Your docker-compose setup is working correctly") - return 0 - else: - print("❌ SERVICES STARTUP TEST FAILED") - print("⚠️ Some services may have issues starting") - return 1 - -if __name__ == "__main__": - exit_code = main() - sys.exit(exit_code) \ No newline at end of file diff --git a/test_training_safeguards.sh b/test_training_safeguards.sh deleted file mode 100755 index d8ac2f0c..00000000 --- a/test_training_safeguards.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/bin/bash - -echo "🧪 Testing Training Safeguards" -echo "=============================" - -# Create user and tenant without importing data -echo "1. Creating user and tenant..." - -# Register user -EMAIL="training.test.$(date +%s)@bakery.com" -REGISTER_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/register" \ - -H "Content-Type: application/json" \ - -d "{\"email\": \"$EMAIL\", \"password\": \"TestPassword123!\", \"full_name\": \"Training Test User\", \"role\": \"admin\"}") - -ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('access_token', '')) -except: - pass -" 2>/dev/null) - -if [ -z "$ACCESS_TOKEN" ]; then - echo "❌ User registration failed" - echo "Response: $REGISTER_RESPONSE" - exit 1 -fi - -echo "✅ User registered successfully" - -# Create tenant -TENANT_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/register" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"name": "Training Test Bakery", "business_type": "bakery", "address": "Test Address", "city": "Madrid", "postal_code": "28001", "phone": "+34600123456"}') - -TENANT_ID=$(echo "$TENANT_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('id', '')) -except: - pass -" 2>/dev/null) - -if [ -z "$TENANT_ID" ]; then - echo "❌ Tenant creation failed" - echo "Response: $TENANT_RESPONSE" - exit 1 -fi - -echo "✅ Tenant created: $TENANT_ID" - -# 2. Test training WITHOUT data (should fail gracefully) -echo "" -echo "2. Testing training WITHOUT sales data (should fail gracefully)..." -TRAINING_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{}') - -echo "Training Response (no data):" -echo "$TRAINING_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$TRAINING_RESPONSE" - -# Check if the job was created but will fail -JOB_ID=$(echo "$TRAINING_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('job_id', '')) -except: - pass -" 2>/dev/null) - -if [ -n "$JOB_ID" ]; then - echo "✅ Training job created: $JOB_ID" - echo "⏳ Waiting 10 seconds to see if safeguard triggers..." - sleep 10 - - # Check training job status - STATUS_RESPONSE=$(curl -s "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs/$JOB_ID/status" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - - echo "Job Status:" - echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE" -else - echo "ℹ️ No job ID returned - training may have been rejected immediately" -fi - -echo "" -echo "3. Now importing some test data..." -IMPORT_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/sales/import" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"data": "date,product,quantity,revenue\n2024-01-01,bread,10,20.0\n2024-01-02,croissant,5,15.0\n2024-01-03,pastry,8,24.0", "data_format": "csv", "filename": "test_data.csv"}') - -echo "Import Response:" -echo "$IMPORT_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$IMPORT_RESPONSE" - -echo "" -echo "4. Testing training WITH sales data..." -TRAINING_WITH_DATA_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{}') - -echo "Training Response (with data):" -echo "$TRAINING_WITH_DATA_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$TRAINING_WITH_DATA_RESPONSE" - -JOB_ID_WITH_DATA=$(echo "$TRAINING_WITH_DATA_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('job_id', '')) -except: - pass -" 2>/dev/null) - -if [ -n "$JOB_ID_WITH_DATA" ]; then - echo "✅ Training job with data created: $JOB_ID_WITH_DATA" - echo "⏳ Monitoring progress for 30 seconds..." - - for i in {1..6}; do - sleep 5 - STATUS_RESPONSE=$(curl -s "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs/$JOB_ID_WITH_DATA/status" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - - STATUS=$(echo "$STATUS_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('status', 'unknown')) -except: - print('error') -" 2>/dev/null) - - echo "[$i/6] Status: $STATUS" - - if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then - echo "Final Status Response:" - echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE" - break - fi - done -fi - -echo "" -echo "🏁 Training safeguard test completed!" \ No newline at end of file diff --git a/test_training_with_data.sh b/test_training_with_data.sh deleted file mode 100755 index c831b385..00000000 --- a/test_training_with_data.sh +++ /dev/null @@ -1,171 +0,0 @@ -#!/bin/bash - -echo "🧪 Testing Training with Actual Sales Data" -echo "========================================" - -# Create user and tenant -echo "1. Creating user and tenant..." - -EMAIL="training.withdata.$(date +%s)@bakery.com" -REGISTER_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/register" \ - -H "Content-Type: application/json" \ - -d "{\"email\": \"$EMAIL\", \"password\": \"TestPassword123!\", \"full_name\": \"Training Test User\", \"role\": \"admin\"}") - -ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('access_token', '')) -except: - pass -" 2>/dev/null) - -if [ -z "$ACCESS_TOKEN" ]; then - echo "❌ User registration failed" - echo "Response: $REGISTER_RESPONSE" - exit 1 -fi - -echo "✅ User registered successfully" - -# Create tenant -TENANT_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/register" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"name": "Training Test Bakery", "business_type": "bakery", "address": "Test Address", "city": "Madrid", "postal_code": "28001", "phone": "+34600123456"}') - -TENANT_ID=$(echo "$TENANT_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('id', '')) -except: - pass -" 2>/dev/null) - -if [ -z "$TENANT_ID" ]; then - echo "❌ Tenant creation failed" - echo "Response: $TENANT_RESPONSE" - exit 1 -fi - -echo "✅ Tenant created: $TENANT_ID" - -# 2. Import sales data using file upload -echo "" -echo "2. Importing sales data using file upload..." - -IMPORT_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/sales/import" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -F "file=@test_sales_data.csv" \ - -F "file_format=csv") - -HTTP_CODE=$(echo "$IMPORT_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -IMPORT_RESPONSE=$(echo "$IMPORT_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Import HTTP Status Code: $HTTP_CODE" -echo "Import Response:" -echo "$IMPORT_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$IMPORT_RESPONSE" - -if [ "$HTTP_CODE" = "200" ]; then - echo "✅ Data import successful!" -else - echo "❌ Data import failed" - exit 1 -fi - -# 3. Test training with data -echo "" -echo "3. Testing training WITH imported sales data..." -TRAINING_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{}') - -echo "Training Response:" -echo "$TRAINING_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$TRAINING_RESPONSE" - -JOB_ID=$(echo "$TRAINING_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('job_id', '')) -except: - pass -" 2>/dev/null) - -if [ -n "$JOB_ID" ]; then - echo "✅ Training job created with data: $JOB_ID" - echo "⏳ Monitoring progress for 60 seconds..." - - for i in {1..12}; do - sleep 5 - STATUS_RESPONSE=$(curl -s "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs/$JOB_ID/status" \ - -H "Authorization: Bearer $ACCESS_TOKEN" 2>/dev/null) - - STATUS=$(echo "$STATUS_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('status', 'unknown')) -except: - print('error') -" 2>/dev/null) - - PROGRESS=$(echo "$STATUS_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('progress', 0)) -except: - print(0) -" 2>/dev/null) - - CURRENT_STEP=$(echo "$STATUS_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('current_step', 'unknown')) -except: - print('unknown') -" 2>/dev/null) - - echo "[$i/12] Status: $STATUS | Progress: $PROGRESS% | Step: $CURRENT_STEP" - - if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then - echo "" - echo "Final Status Response:" - echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE" - - if [ "$STATUS" = "completed" ]; then - echo "✅ Training completed successfully!" - - # Test forecast endpoint - echo "" - echo "4. Testing forecast endpoint..." - FORECAST_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/forecasts" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -d '{"product_name": "bread", "days_ahead": 7}') - - echo "Forecast Response:" - echo "$FORECAST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_RESPONSE" - - elif [ "$STATUS" = "failed" ]; then - echo "❌ Training failed" - fi - break - fi - done - - if [ "$i" -eq 12 ]; then - echo "⏰ Training still in progress after 60 seconds" - echo "Final status check:" - echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE" - fi -else - echo "❌ No training job ID returned" -fi - -echo "" -echo "🏁 Training with data test completed!" \ No newline at end of file diff --git a/tests/debug_admin_role.sh b/tests/debug_admin_role.sh deleted file mode 100755 index 95050062..00000000 --- a/tests/debug_admin_role.sh +++ /dev/null @@ -1,436 +0,0 @@ -#!/bin/bash - -# ================================================================= -# COMPREHENSIVE ADMIN ROLE DEBUG SCRIPT -# ================================================================= -# This script will trace the entire flow of admin role assignment -# from registration through JWT token creation to API calls - -# Configuration -API_BASE="http://localhost:8000" -AUTH_BASE="http://localhost:8001" -TEST_EMAIL="debug.admin.test.$(date +%s)@bakery.com" -TEST_PASSWORD="DebugPassword123!" -TEST_NAME="Debug Admin User" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -PURPLE='\033[0;35m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color - -echo -e "${CYAN}🔍 COMPREHENSIVE ADMIN ROLE DEBUG${NC}" -echo -e "${CYAN}=================================${NC}" -echo "Test Email: $TEST_EMAIL" -echo "API Base: $API_BASE" -echo "Auth Base: $AUTH_BASE" -echo "" - -# Utility functions -log_step() { - echo -e "${BLUE}📋 $1${NC}" -} - -log_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -log_error() { - echo -e "${RED}❌ $1${NC}" -} - -log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -log_debug() { - echo -e "${PURPLE}🐛 DEBUG: $1${NC}" -} - -# Function to decode JWT token (requires jq and base64) -decode_jwt() { - local token="$1" - local part="$2" # header=0, payload=1, signature=2 - - if [ -z "$token" ]; then - echo "No token provided" - return 1 - fi - - # Split token by dots - IFS='.' read -ra PARTS <<< "$token" - - if [ ${#PARTS[@]} -ne 3 ]; then - echo "Invalid JWT format" - return 1 - fi - - # Decode the specified part (default to payload) - local part_index=${part:-1} - local encoded_part="${PARTS[$part_index]}" - - # Add padding if needed - local padding=$(( 4 - ${#encoded_part} % 4 )) - if [ $padding -ne 4 ]; then - encoded_part="${encoded_part}$(printf '%*s' $padding | tr ' ' '=')" - fi - - # Decode and format as JSON - echo "$encoded_part" | base64 -d 2>/dev/null | jq '.' 2>/dev/null || echo "Failed to decode JWT part" -} - -# Function to extract JSON field -extract_json_field() { - local json="$1" - local field="$2" - echo "$json" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('$field', '')) -except: - print('') -" 2>/dev/null -} - -# ================================================================= -# STEP 1: VERIFY SERVICES ARE RUNNING -# ================================================================= - -log_step "Step 1: Verifying services are running" - -# Check API Gateway -if ! curl -s "$API_BASE/health" > /dev/null; then - log_error "API Gateway is not responding at $API_BASE" - echo "Please ensure services are running: docker-compose up -d" - exit 1 -fi -log_success "API Gateway is responding" - -# Check Auth Service directly -if ! curl -s "$AUTH_BASE/health" > /dev/null; then - log_error "Auth Service is not responding at $AUTH_BASE" - exit 1 -fi -log_success "Auth Service is responding" - -echo "" - -# ================================================================= -# STEP 2: USER REGISTRATION WITH DETAILED DEBUGGING -# ================================================================= - -log_step "Step 2: User Registration with Admin Role" -echo "Email: $TEST_EMAIL" -echo "Role: admin (explicitly set)" -echo "" - -log_debug "Sending registration request..." - -# Registration request with explicit role -REGISTER_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$API_BASE/api/v1/auth/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"email\": \"$TEST_EMAIL\", - \"password\": \"$TEST_PASSWORD\", - \"full_name\": \"$TEST_NAME\", - \"role\": \"admin\" - }") - -# Extract HTTP code and response -HTTP_CODE=$(echo "$REGISTER_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -REGISTER_RESPONSE=$(echo "$REGISTER_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Registration HTTP Status: $HTTP_CODE" -echo "Registration Response:" -echo "$REGISTER_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$REGISTER_RESPONSE" - -if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then - log_error "Registration failed with HTTP $HTTP_CODE" - exit 1 -fi - -# Extract user information from registration response -USER_ID=$(extract_json_field "$REGISTER_RESPONSE" "user_id") -if [ -z "$USER_ID" ]; then - # Try alternative field names - USER_ID=$(echo "$REGISTER_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - user = data.get('user', {}) - print(user.get('id', data.get('id', ''))) -except: - print('') -" 2>/dev/null) -fi - -ACCESS_TOKEN=$(extract_json_field "$REGISTER_RESPONSE" "access_token") -REFRESH_TOKEN=$(extract_json_field "$REGISTER_RESPONSE" "refresh_token") - -log_debug "Extracted from registration:" -echo " User ID: $USER_ID" -echo " Access Token: ${ACCESS_TOKEN:0:50}..." -echo " Refresh Token: ${REFRESH_TOKEN:0:50}..." - -echo "" - -# ================================================================= -# STEP 3: DECODE AND ANALYZE JWT TOKEN -# ================================================================= - -log_step "Step 3: JWT Token Analysis" - -if [ -n "$ACCESS_TOKEN" ]; then - log_debug "Decoding JWT Header:" - JWT_HEADER=$(decode_jwt "$ACCESS_TOKEN" 0) - echo "$JWT_HEADER" - - echo "" - log_debug "Decoding JWT Payload:" - JWT_PAYLOAD=$(decode_jwt "$ACCESS_TOKEN" 1) - echo "$JWT_PAYLOAD" - - # Extract role from JWT payload - JWT_ROLE=$(echo "$JWT_PAYLOAD" | jq -r '.role // "NOT_FOUND"' 2>/dev/null) - JWT_USER_ID=$(echo "$JWT_PAYLOAD" | jq -r '.user_id // "NOT_FOUND"' 2>/dev/null) - JWT_EMAIL=$(echo "$JWT_PAYLOAD" | jq -r '.email // "NOT_FOUND"' 2>/dev/null) - - echo "" - log_debug "JWT Payload Analysis:" - echo " Role in JWT: $JWT_ROLE" - echo " User ID in JWT: $JWT_USER_ID" - echo " Email in JWT: $JWT_EMAIL" - - if [ "$JWT_ROLE" = "admin" ]; then - log_success "JWT contains admin role correctly" - else - log_error "JWT role is '$JWT_ROLE', expected 'admin'" - fi -else - log_error "No access token received in registration response" - echo "Cannot analyze JWT" -fi - -echo "" - -# ================================================================= -# STEP 4: VERIFY USER PROFILE ENDPOINT -# ================================================================= - -log_step "Step 4: User Profile Verification" - -if [ -n "$ACCESS_TOKEN" ]; then - log_debug "Calling /users/me endpoint..." - - USER_PROFILE_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/me" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - - # Extract HTTP code and response - HTTP_CODE=$(echo "$USER_PROFILE_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) - USER_PROFILE_RESPONSE=$(echo "$USER_PROFILE_RESPONSE" | sed '/HTTP_CODE:/d') - - echo "User Profile HTTP Status: $HTTP_CODE" - echo "User Profile Response:" - echo "$USER_PROFILE_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$USER_PROFILE_RESPONSE" - - if [ "$HTTP_CODE" = "200" ]; then - PROFILE_ROLE=$(extract_json_field "$USER_PROFILE_RESPONSE" "role") - PROFILE_EMAIL=$(extract_json_field "$USER_PROFILE_RESPONSE" "email") - PROFILE_USER_ID=$(extract_json_field "$USER_PROFILE_RESPONSE" "id") - - echo "" - log_debug "Profile endpoint analysis:" - echo " Role from profile: $PROFILE_ROLE" - echo " Email from profile: $PROFILE_EMAIL" - echo " User ID from profile: $PROFILE_USER_ID" - - if [ "$PROFILE_ROLE" = "admin" ]; then - log_success "Profile endpoint shows admin role correctly" - else - log_error "Profile endpoint shows role '$PROFILE_ROLE', expected 'admin'" - fi - else - log_error "Failed to get user profile (HTTP $HTTP_CODE)" - fi -else - log_error "No access token available for profile verification" -fi - -echo "" - -# ================================================================= -# STEP 5: DIRECT DATABASE VERIFICATION (if possible) -# ================================================================= - -log_step "Step 5: Database Verification" - -log_debug "Attempting to verify user role in database..." - -# Try to connect to the auth database directly -DB_QUERY_RESULT=$(docker exec bakery-auth-db psql -U auth_user -d auth_db -t -c "SELECT id, email, role, created_at FROM users WHERE email='$TEST_EMAIL';" 2>/dev/null || echo "DB_ACCESS_FAILED") - -if [ "$DB_QUERY_RESULT" != "DB_ACCESS_FAILED" ]; then - echo "Database Query Result:" - echo "$DB_QUERY_RESULT" - - # Parse the database result - DB_ROLE=$(echo "$DB_QUERY_RESULT" | awk -F'|' '{print $3}' | tr -d ' ') - - log_debug "Role in database: '$DB_ROLE'" - - if [ "$DB_ROLE" = "admin" ]; then - log_success "Database shows admin role correctly" - else - log_error "Database shows role '$DB_ROLE', expected 'admin'" - fi -else - log_warning "Cannot access database directly (this is normal in some setups)" - echo "You can manually check with:" - echo " docker exec bakery-auth-db psql -U auth_user -d auth_db -c \"SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';\"" -fi - -echo "" - -# ================================================================= -# STEP 6: TEST ADMIN ENDPOINT ACCESS -# ================================================================= - -log_step "Step 6: Testing Admin Endpoint Access" - -if [ -n "$ACCESS_TOKEN" ] && [ -n "$USER_ID" ]; then - log_debug "Attempting to call admin deletion preview endpoint..." - - ADMIN_TEST_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/delete/$USER_ID/deletion-preview" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - - # Extract HTTP code and response - HTTP_CODE=$(echo "$ADMIN_TEST_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) - ADMIN_TEST_RESPONSE=$(echo "$ADMIN_TEST_RESPONSE" | sed '/HTTP_CODE:/d') - - echo "Admin Endpoint HTTP Status: $HTTP_CODE" - echo "Admin Endpoint Response:" - echo "$ADMIN_TEST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$ADMIN_TEST_RESPONSE" - - case "$HTTP_CODE" in - "200") - log_success "Admin endpoint access successful!" - ;; - "403") - log_error "Access denied - user does not have admin privileges" - echo "This confirms the role issue!" - ;; - "401") - log_error "Authentication failed - token may be invalid" - ;; - *) - log_warning "Unexpected response from admin endpoint (HTTP $HTTP_CODE)" - ;; - esac -else - log_error "Cannot test admin endpoint - missing access token or user ID" -fi - -echo "" - -# ================================================================= -# STEP 7: AUTH SERVICE LOGS ANALYSIS -# ================================================================= - -log_step "Step 7: Auth Service Logs Analysis" - -log_debug "Checking recent auth service logs..." - -AUTH_LOGS=$(docker logs --tail 50 bakery-auth-service 2>/dev/null | grep -E "(register|role|admin|$TEST_EMAIL)" || echo "NO_LOGS_FOUND") - -if [ "$AUTH_LOGS" != "NO_LOGS_FOUND" ]; then - echo "Recent Auth Service Logs (filtered):" - echo "$AUTH_LOGS" -else - log_warning "Cannot access auth service logs" - echo "You can check manually with:" - echo " docker logs bakery-auth-service | grep -E \"(register|role|admin)\"" -fi - -echo "" - -# ================================================================= -# STEP 8: SUMMARY AND RECOMMENDATIONS -# ================================================================= - -log_step "Step 8: Debug Summary and Recommendations" - -echo -e "${CYAN}🔍 DEBUG SUMMARY${NC}" -echo -e "${CYAN}===============${NC}" - -echo "" -echo "Test User Details:" -echo " Email: $TEST_EMAIL" -echo " Expected Role: admin" -echo " User ID: ${USER_ID:-'NOT_EXTRACTED'}" - -echo "" -echo "Token Analysis:" -echo " Access Token Received: $([ -n "$ACCESS_TOKEN" ] && echo 'YES' || echo 'NO')" -echo " Role in JWT: ${JWT_ROLE:-'NOT_FOUND'}" -echo " JWT User ID: ${JWT_USER_ID:-'NOT_FOUND'}" - -echo "" -echo "API Responses:" -echo " Registration HTTP: ${HTTP_CODE:-'UNKNOWN'}" -echo " Profile Role: ${PROFILE_ROLE:-'NOT_CHECKED'}" -echo " Admin Endpoint Access: $([ -n "$ADMIN_TEST_RESPONSE" ] && echo "TESTED" || echo "NOT_TESTED")" - -echo "" -echo "Database Verification:" -echo " DB Role: ${DB_ROLE:-'NOT_CHECKED'}" - -echo "" -log_debug "Potential Issues:" - -# Issue 1: Role not in JWT -if [ "$JWT_ROLE" != "admin" ]; then - echo " ❌ Role not correctly encoded in JWT token" - echo " → Check auth service JWT creation logic" - echo " → Verify SecurityManager.create_access_token includes role" -fi - -# Issue 2: Registration not setting role -if [ "$PROFILE_ROLE" != "admin" ]; then - echo " ❌ User profile doesn't show admin role" - echo " → Check user registration saves role correctly" - echo " → Verify database schema allows role field" -fi - -# Issue 3: Admin endpoint still denies access -if [ "$HTTP_CODE" = "403" ]; then - echo " ❌ Admin endpoint denies access despite admin role" - echo " → Check require_admin_role_dep implementation" - echo " → Verify role extraction from token in auth decorators" -fi - -echo "" -echo -e "${YELLOW}🔧 RECOMMENDED NEXT STEPS:${NC}" -echo "1. Check the auth service logs during registration" -echo "2. Verify the database schema has a 'role' column" -echo "3. Check if the JWT creation includes the role field" -echo "4. Verify the role extraction in get_current_user_dep" -echo "5. Test with a direct database role update if needed" - -echo "" -echo -e "${CYAN}🧪 Manual Verification Commands:${NC}" -echo "# Check database directly:" -echo "docker exec bakery-auth-db psql -U auth_user -d auth_db -c \"SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';\"" -echo "" -echo "# Check auth service logs:" -echo "docker logs bakery-auth-service | grep -A5 -B5 '$TEST_EMAIL'" -echo "" -echo "# Decode JWT manually:" -echo "echo '$ACCESS_TOKEN' | cut -d. -f2 | base64 -d | jq ." - -echo "" -log_success "Debug script completed!" -echo -e "${YELLOW}Review the analysis above to identify the root cause.${NC}" \ No newline at end of file diff --git a/tests/debug_onboarding_flow.sh b/tests/debug_onboarding_flow.sh deleted file mode 100755 index 28601819..00000000 --- a/tests/debug_onboarding_flow.sh +++ /dev/null @@ -1,361 +0,0 @@ -#!/bin/bash - -# ================================================================= -# ONBOARDING FLOW DEBUG SCRIPT -# ================================================================= -# This script replicates the exact onboarding flow to debug the 403 issue - -# Configuration -API_BASE="http://localhost:8000" -TEST_EMAIL="onboarding.debug.$(date +%s)@bakery.com" -TEST_PASSWORD="OnboardingDebug123!" -TEST_NAME="Onboarding Debug User" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -PURPLE='\033[0;35m' -CYAN='\033[0;36m' -NC='\033[0m' - -echo -e "${CYAN}🔍 ONBOARDING FLOW DEBUG - EXACT REPLICATION${NC}" -echo -e "${CYAN}=============================================${NC}" -echo "Test Email: $TEST_EMAIL" -echo "API Base: $API_BASE" -echo "" - -# Utility functions -log_step() { - echo -e "${BLUE}📋 $1${NC}" -} - -log_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -log_error() { - echo -e "${RED}❌ $1${NC}" -} - -log_debug() { - echo -e "${PURPLE}🐛 DEBUG: $1${NC}" -} - -extract_json_field() { - local json="$1" - local field="$2" - echo "$json" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - user = data.get('user', {}) - print(user.get('$field', data.get('$field', ''))) -except: - print('') -" 2>/dev/null -} - -# ================================================================= -# STEP 1: REGISTER USER (EXACTLY LIKE ONBOARDING SCRIPT) -# ================================================================= - -log_step "Step 1: User Registration (Onboarding Style)" -echo "Email: $TEST_EMAIL" -echo "Role: admin (explicitly set)" -echo "" - -log_debug "Registering via API Gateway (same as onboarding script)..." - -REGISTER_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/register" \ - -H "Content-Type: application/json" \ - -d "{ - \"email\": \"$TEST_EMAIL\", - \"password\": \"$TEST_PASSWORD\", - \"full_name\": \"$TEST_NAME\", - \"role\": \"admin\" - }") - -echo "Registration Response:" -echo "$REGISTER_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$REGISTER_RESPONSE" - -# Extract user ID exactly like onboarding script -USER_ID=$(echo "$REGISTER_RESPONSE" | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - user = data.get('user', {}) - print(user.get('id', '')) -except: - print('') -") - -if [ -n "$USER_ID" ]; then - log_success "User ID extracted: $USER_ID" -else - log_error "Failed to extract user ID" - exit 1 -fi - -echo "" - -# ================================================================= -# STEP 2: LOGIN (EXACTLY LIKE ONBOARDING SCRIPT) -# ================================================================= - -log_step "Step 2: User Login (Onboarding Style)" - -LOGIN_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/login" \ - -H "Content-Type: application/json" \ - -d "{ - \"email\": \"$TEST_EMAIL\", - \"password\": \"$TEST_PASSWORD\" - }") - -echo "Login Response:" -echo "$LOGIN_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$LOGIN_RESPONSE" - -ACCESS_TOKEN=$(extract_json_field "$LOGIN_RESPONSE" "access_token") - -if [ -n "$ACCESS_TOKEN" ]; then - log_success "Access token obtained: ${ACCESS_TOKEN:0:50}..." -else - log_error "Failed to extract access token" - exit 1 -fi - -echo "" - -# ================================================================= -# STEP 3: CHECK PROFILE ENDPOINT -# ================================================================= - -log_step "Step 3: Check Profile Endpoint" - -PROFILE_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/me" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - -HTTP_CODE=$(echo "$PROFILE_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -PROFILE_RESPONSE=$(echo "$PROFILE_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Profile HTTP Status: $HTTP_CODE" -echo "Profile Response:" -echo "$PROFILE_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PROFILE_RESPONSE" - -PROFILE_ROLE=$(extract_json_field "$PROFILE_RESPONSE" "role") -echo "" -log_debug "Profile role: $PROFILE_ROLE" - -if [ "$PROFILE_ROLE" = "admin" ]; then - log_success "Profile shows admin role correctly" -else - log_error "Profile shows role '$PROFILE_ROLE', expected 'admin'" -fi - -echo "" - -# ================================================================= -# STEP 4: TEST ADMIN DELETION PREVIEW (EXACTLY LIKE ONBOARDING) -# ================================================================= - -log_step "Step 4: Admin Deletion Preview (Onboarding Style)" - -log_debug "Calling deletion preview endpoint..." - -DELETION_PREVIEW_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/delete/$USER_ID/deletion-preview" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - -H "Content-Type: application/json") - -HTTP_CODE=$(echo "$DELETION_PREVIEW_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) -DELETION_PREVIEW_RESPONSE=$(echo "$DELETION_PREVIEW_RESPONSE" | sed '/HTTP_CODE:/d') - -echo "Deletion Preview HTTP Status: $HTTP_CODE" -echo "Deletion Preview Response:" -echo "$DELETION_PREVIEW_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$DELETION_PREVIEW_RESPONSE" - -case "$HTTP_CODE" in - "200") - log_success "Deletion preview successful!" - ;; - "403") - log_error "403 Forbidden - Admin access denied!" - echo "This is the same error as in onboarding script" - ;; - "401") - log_error "401 Unauthorized - Token issue" - ;; - *) - log_error "Unexpected HTTP status: $HTTP_CODE" - ;; -esac - -echo "" - -# ================================================================= -# STEP 5: TEST ACTUAL DELETION (EXACTLY LIKE ONBOARDING) -# ================================================================= - -log_step "Step 5: Admin User Deletion (Onboarding Style)" - -if [ "$HTTP_CODE" = "200" ]; then - log_debug "Attempting actual deletion..." - - DELETION_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X DELETE "$API_BASE/api/v1/users/delete/$USER_ID" \ - -H "Authorization: Bearer $ACCESS_TOKEN") - - HTTP_CODE=$(echo "$DELETION_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) - DELETION_RESPONSE=$(echo "$DELETION_RESPONSE" | sed '/HTTP_CODE:/d') - - echo "Deletion HTTP Status: $HTTP_CODE" - echo "Deletion Response:" - echo "$DELETION_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$DELETION_RESPONSE" - - case "$HTTP_CODE" in - "200") - log_success "Admin deletion successful!" - ;; - "403") - log_error "403 Forbidden - Admin deletion denied!" - ;; - *) - log_error "Deletion failed with HTTP $HTTP_CODE" - ;; - esac -else - log_error "Skipping deletion test due to preview failure" -fi - -echo "" - -# ================================================================= -# STEP 6: DETAILED TOKEN ANALYSIS -# ================================================================= - -log_step "Step 6: Detailed Token Analysis" - -log_debug "Decoding access token from login..." - -if [ -n "$ACCESS_TOKEN" ]; then - # Decode JWT payload - JWT_PAYLOAD=$(echo "$ACCESS_TOKEN" | cut -d. -f2) - # Add padding if needed - JWT_PAYLOAD="${JWT_PAYLOAD}$(printf '%*s' $((4 - ${#JWT_PAYLOAD} % 4)) | tr ' ' '=')" - - echo "JWT Payload:" - echo "$JWT_PAYLOAD" | base64 -d 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "Failed to decode" - - JWT_ROLE=$(echo "$JWT_PAYLOAD" | base64 -d 2>/dev/null | python3 -c " -import json, sys -try: - data = json.load(sys.stdin) - print(data.get('role', 'NOT_FOUND')) -except: - print('DECODE_ERROR') -" 2>/dev/null) - - echo "" - log_debug "JWT token role: $JWT_ROLE" -else - log_error "No access token available for analysis" -fi - -echo "" - -# ================================================================= -# STEP 7: CHECK GATEWAY VS DIRECT AUTH -# ================================================================= - -log_step "Step 7: Gateway vs Direct Auth Comparison" - -log_debug "Testing direct auth service call..." - -DIRECT_PROFILE=$(curl -s -X GET "http://localhost:8001/me" \ - -H "Authorization: Bearer $ACCESS_TOKEN" 2>/dev/null || echo "DIRECT_FAILED") - -if [ "$DIRECT_PROFILE" != "DIRECT_FAILED" ]; then - echo "Direct Auth Service Profile:" - echo "$DIRECT_PROFILE" | python3 -m json.tool 2>/dev/null || echo "$DIRECT_PROFILE" - - DIRECT_ROLE=$(extract_json_field "$DIRECT_PROFILE" "role") - log_debug "Direct auth service role: $DIRECT_ROLE" -else - log_debug "Direct auth service call failed (expected in some setups)" -fi - -echo "" - -# ================================================================= -# STEP 8: CHECK AUTH SERVICE LOGS -# ================================================================= - -log_step "Step 8: Auth Service Logs for This Session" - -log_debug "Checking auth service logs for this user..." - -AUTH_LOGS=$(docker logs --tail 50 bakery-auth-service 2>/dev/null | grep -E "($TEST_EMAIL|$USER_ID)" || echo "NO_LOGS_FOUND") - -if [ "$AUTH_LOGS" != "NO_LOGS_FOUND" ]; then - echo "Auth Service Logs (filtered for this user):" - echo "$AUTH_LOGS" -else - log_debug "No specific logs found for this user" -fi - -echo "" - -# ================================================================= -# STEP 9: SUMMARY AND COMPARISON -# ================================================================= - -log_step "Step 9: Onboarding Flow Analysis Summary" - -echo -e "${CYAN}🔍 ONBOARDING FLOW ANALYSIS${NC}" -echo -e "${CYAN}===========================${NC}" - -echo "" -echo "Registration Results:" -echo " User ID: $USER_ID" -echo " Access Token: $([ -n "$ACCESS_TOKEN" ] && echo 'YES' || echo 'NO')" - -echo "" -echo "Authentication Flow:" -echo " Profile Role: $PROFILE_ROLE" -echo " JWT Role: $JWT_ROLE" -echo " Direct Auth Role: ${DIRECT_ROLE:-'NOT_TESTED'}" - -echo "" -echo "Admin Endpoint Tests:" -echo " Deletion Preview: HTTP $HTTP_CODE" -echo " Admin Access: $([ "$HTTP_CODE" = "200" ] && echo 'SUCCESS' || echo 'FAILED')" - -echo "" -if [ "$HTTP_CODE" != "200" ]; then - echo -e "${RED}🚨 ISSUE IDENTIFIED:${NC}" - echo "The onboarding flow is still getting 403 errors" - echo "" - echo -e "${YELLOW}Possible causes:${NC}" - echo "1. Token from login different from registration token" - echo "2. Gateway headers not set correctly" - echo "3. Role being overridden somewhere in the flow" - echo "4. Cache or session issue" - echo "" - echo -e "${YELLOW}Next steps:${NC}" - echo "1. Compare JWT tokens from registration vs login" - echo "2. Check gateway request headers" - echo "3. Verify auth service restart picked up the fix" - echo "4. Check if there are multiple auth service instances" -else - log_success "Onboarding flow working correctly!" -fi - -echo "" -echo -e "${CYAN}Manual commands to investigate:${NC}" -echo "# Check user in database:" -echo "docker exec bakery-auth-db psql -U auth_user -d auth_db -c \"SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';\"" -echo "" -echo "# Restart auth service:" -echo "docker-compose restart auth-service" -echo "" -echo "# Check auth service status:" -echo "docker-compose ps auth-service" \ No newline at end of file diff --git a/tests/verify_db_schema.sh b/tests/verify_db_schema.sh deleted file mode 100755 index a507a16e..00000000 --- a/tests/verify_db_schema.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/bin/bash - -# ================================================================= -# DATABASE SCHEMA AND DATA VERIFICATION SCRIPT -# ================================================================= -# This script checks the auth database schema and data - -echo "🔍 DATABASE VERIFICATION" -echo "========================" - -# Colors -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Check if auth database container is running -if ! docker ps | grep -q "bakery-auth-db"; then - echo -e "${RED}❌ Auth database container is not running${NC}" - echo "Start with: docker-compose up -d auth-db" - exit 1 -fi - -echo -e "${GREEN}✅ Auth database container is running${NC}" -echo "" - -# 1. Check database schema -echo "📋 1. CHECKING DATABASE SCHEMA" -echo "==============================" - -echo "Users table structure:" -docker exec bakery-auth-db psql -U auth_user -d auth_db -c "\d users;" 2>/dev/null || { - echo -e "${RED}❌ Cannot access database or users table doesn't exist${NC}" - exit 1 -} - -echo "" -echo "Checking if 'role' column exists:" -ROLE_COLUMN=$(docker exec bakery-auth-db psql -U auth_user -d auth_db -t -c "SELECT column_name FROM information_schema.columns WHERE table_name='users' AND column_name='role';" 2>/dev/null | tr -d ' ') - -if [ "$ROLE_COLUMN" = "role" ]; then - echo -e "${GREEN}✅ 'role' column exists in users table${NC}" -else - echo -e "${RED}❌ 'role' column is missing from users table${NC}" - echo "This is likely the root cause!" - echo "" - echo "Available columns in users table:" - docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='users';" - exit 1 -fi - -echo "" - -# 2. Check existing users and their roles -echo "📋 2. CHECKING EXISTING USERS" -echo "=============================" - -echo "All users in database:" -docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT id, email, role, is_active, created_at FROM users ORDER BY created_at DESC LIMIT 10;" - -echo "" - -# 3. Check for test users -echo "📋 3. CHECKING FOR TEST USERS" -echo "=============================" - -echo "Test users (containing 'test' in email):" -docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT id, email, role, is_active, created_at FROM users WHERE email LIKE '%test%' ORDER BY created_at DESC;" - -echo "" - -# 4. Check database constraints and defaults -echo "📋 4. CHECKING ROLE CONSTRAINTS" -echo "===============================" - -echo "Role column details:" -docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name='users' AND column_name='role';" - -echo "" -echo "Role check constraints (if any):" -docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT conname, consrc FROM pg_constraint WHERE conrelid = 'users'::regclass AND consrc LIKE '%role%';" 2>/dev/null || echo "No role constraints found" - -echo "" - -# 5. Test role insertion -echo "📋 5. TESTING ROLE INSERTION" -echo "============================" - -TEST_EMAIL="schema.test.$(date +%s)@example.com" - -echo "Creating test user with admin role:" -docker exec bakery-auth-db psql -U auth_user -d auth_db -c " -INSERT INTO users (id, email, full_name, hashed_password, role, is_active, is_verified, created_at, updated_at) -VALUES (gen_random_uuid(), '$TEST_EMAIL', 'Schema Test', 'dummy_hash', 'admin', true, false, NOW(), NOW()); -" 2>/dev/null - -if [ $? -eq 0 ]; then - echo -e "${GREEN}✅ Successfully inserted user with admin role${NC}" - - echo "Verifying insertion:" - docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';" - - echo "Cleaning up test user:" - docker exec bakery-auth-db psql -U auth_user -d auth_db -c "DELETE FROM users WHERE email='$TEST_EMAIL';" 2>/dev/null -else - echo -e "${RED}❌ Failed to insert user with admin role${NC}" - echo "This indicates a database constraint or permission issue" -fi - -echo "" - -# 6. Check for migration history -echo "📋 6. CHECKING MIGRATION HISTORY" -echo "=================================" - -echo "Alembic version table (if exists):" -docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT * FROM alembic_version;" 2>/dev/null || echo "No alembic_version table found" - -echo "" - -echo "📋 SUMMARY AND RECOMMENDATIONS" -echo "===============================" - -# Check if we found any obvious issues -ISSUES_FOUND=0 - -# Check if role column exists -if [ "$ROLE_COLUMN" != "role" ]; then - echo -e "${RED}❌ CRITICAL: 'role' column missing from users table${NC}" - echo " → Run database migrations: alembic upgrade head" - echo " → Or add the column manually" - ISSUES_FOUND=1 -fi - -# Check if we can insert admin roles -docker exec bakery-auth-db psql -U auth_user -d auth_db -c "INSERT INTO users (id, email, full_name, hashed_password, role, is_active, is_verified, created_at, updated_at) VALUES (gen_random_uuid(), 'temp.test@example.com', 'Test', 'hash', 'admin', true, false, NOW(), NOW());" 2>/dev/null -if [ $? -eq 0 ]; then - docker exec bakery-auth-db psql -U auth_user -d auth_db -c "DELETE FROM users WHERE email='temp.test@example.com';" 2>/dev/null -else - echo -e "${RED}❌ ISSUE: Cannot insert users with admin role${NC}" - echo " → Check database constraints or permissions" - ISSUES_FOUND=1 -fi - -if [ $ISSUES_FOUND -eq 0 ]; then - echo -e "${GREEN}✅ Database schema appears correct${NC}" - echo " → The issue is likely in the application code, not the database" - echo " → Check JWT token creation and role extraction logic" -else - echo -e "${YELLOW}⚠️ Database issues found - fix these first${NC}" -fi - -echo "" -echo -e "${YELLOW}🔧 Next steps:${NC}" -echo "1. If role column is missing: Run 'alembic upgrade head' in auth service" -echo "2. If schema is OK: Run the main debug script to check application logic" -echo "3. Check auth service logs during user registration" \ No newline at end of file diff --git a/verify_clean_structure.py b/verify_clean_structure.py deleted file mode 100644 index 55780311..00000000 --- a/verify_clean_structure.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -""" -Clean Structure Verification Script -Verifies that all services can import their key components correctly after cleanup -""" - -import sys -import os -import importlib.util -from pathlib import Path - -def test_import(module_path, module_name): - """Test if a module can be imported without errors""" - try: - spec = importlib.util.spec_from_file_location(module_name, module_path) - if spec is None: - return False, f"Could not create module spec for {module_path}" - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return True, "Import successful" - except Exception as e: - return False, str(e) - -def verify_service_structure(service_name, base_path): - """Verify the structure of a specific service""" - print(f"\n=== Verifying {service_name.upper()} Service Structure ===") - - service_path = base_path / f"services/{service_name}" - - # Key files to check - key_files = [ - "app/main.py", - "app/core/config.py", - "app/core/database.py" - ] - - # API files (if they exist) - api_files = [] - api_path = service_path / "app/api" - if api_path.exists(): - for api_file in api_path.glob("*.py"): - if api_file.name != "__init__.py": - api_files.append(f"app/api/{api_file.name}") - - # Service files (if they exist) - service_files = [] - services_path = service_path / "app/services" - if services_path.exists(): - for service_file in services_path.glob("*.py"): - if service_file.name != "__init__.py": - service_files.append(f"app/services/{service_file.name}") - - all_files = key_files + api_files + service_files - - results = {"success": 0, "failed": 0, "details": []} - - for file_path in all_files: - full_path = service_path / file_path - if not full_path.exists(): - results["details"].append(f"❌ {file_path} - File does not exist") - results["failed"] += 1 - continue - - # Basic syntax check by attempting to compile - try: - with open(full_path, 'r') as f: - content = f.read() - compile(content, str(full_path), 'exec') - results["details"].append(f"✅ {file_path} - Syntax OK") - results["success"] += 1 - except SyntaxError as e: - results["details"].append(f"❌ {file_path} - Syntax Error: {e}") - results["failed"] += 1 - except Exception as e: - results["details"].append(f"⚠️ {file_path} - Warning: {e}") - results["success"] += 1 # Still count as success for non-syntax issues - - # Print results - for detail in results["details"]: - print(f" {detail}") - - success_rate = results["success"] / (results["success"] + results["failed"]) * 100 if (results["success"] + results["failed"]) > 0 else 0 - print(f"\n{service_name.upper()} Results: {results['success']} ✅ | {results['failed']} ❌ | {success_rate:.1f}% success") - - return results["failed"] == 0 - -def main(): - """Main verification function""" - print("🔍 DATABASE ARCHITECTURE REFACTORING - CLEAN STRUCTURE VERIFICATION") - print("=" * 70) - - base_path = Path(__file__).parent - - # Services to verify - services = ["data", "auth", "training", "forecasting", "tenant", "notification"] - - all_services_ok = True - - for service in services: - service_ok = verify_service_structure(service, base_path) - if not service_ok: - all_services_ok = False - - # Verify shared components - print(f"\n=== Verifying SHARED Components ===") - shared_files = [ - "shared/database/base.py", - "shared/database/repository.py", - "shared/database/unit_of_work.py", - "shared/database/transactions.py", - "shared/database/exceptions.py", - "shared/clients/base_service_client.py" - ] - - shared_ok = True - for file_path in shared_files: - full_path = base_path / file_path - if not full_path.exists(): - print(f" ❌ {file_path} - File does not exist") - shared_ok = False - continue - - try: - with open(full_path, 'r') as f: - content = f.read() - compile(content, str(full_path), 'exec') - print(f" ✅ {file_path} - Syntax OK") - except Exception as e: - print(f" ❌ {file_path} - Error: {e}") - shared_ok = False - - # Final summary - print(f"\n" + "=" * 70) - if all_services_ok and shared_ok: - print("🎉 VERIFICATION SUCCESSFUL - All services have clean structure!") - print("✅ All enhanced_*.py files removed") - print("✅ All imports updated to use new structure") - print("✅ All syntax checks passed") - return 0 - else: - print("❌ VERIFICATION FAILED - Issues found in service structure") - return 1 - -if __name__ == "__main__": - exit_code = main() - sys.exit(exit_code) \ No newline at end of file