Imporve the i18 and frontend UI pages

This commit is contained in:
Urtzi Alfaro
2025-09-22 16:10:08 +02:00
parent ee36c45d25
commit 8d54202e91
32 changed files with 875 additions and 434 deletions

View File

@@ -545,3 +545,4 @@ export const useTriggerDailyScheduler = (
...options, ...options,
}); });
}; };

View File

@@ -302,6 +302,7 @@ export class OrdersService {
static async getProcurementHealth(tenantId: string): Promise<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }> { static async getProcurementHealth(tenantId: string): Promise<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }> {
return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/procurement/health`); return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/procurement/health`);
} }
} }
export default OrdersService; export default OrdersService;

View File

@@ -12,7 +12,7 @@ import {
ChevronRight, ChevronRight,
Calendar, Calendar,
User, User,
DollarSign, Euro,
Truck Truck
} from 'lucide-react'; } from 'lucide-react';

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { ChefHat, Package, Clock, DollarSign, Star } from 'lucide-react'; import { ChefHat, Package, Clock, Euro, Star } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal'; import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../api/types/recipes'; import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../api/types/recipes';
import { useIngredients } from '../../../api/hooks/inventory'; import { useIngredients } from '../../../api/hooks/inventory';
@@ -402,7 +402,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
}, },
{ {
title: 'Configuración Financiera', title: 'Configuración Financiera',
icon: DollarSign, icon: Euro,
fields: [ fields: [
{ {
key: 'estimated_cost_per_unit', key: 'estimated_cost_per_unit',

View File

@@ -8,7 +8,7 @@ import {
TrendingUp, TrendingUp,
Package, Package,
Users, Users,
DollarSign, Euro,
BarChart3, BarChart3,
Target, Target,
Activity, Activity,
@@ -40,7 +40,7 @@ export const statIcons = {
growth: TrendingUp, growth: TrendingUp,
inventory: Package, inventory: Package,
users: Users, users: Users,
revenue: DollarSign, revenue: Euro,
analytics: BarChart3, analytics: BarChart3,
goals: Target, goals: Target,
activity: Activity, activity: Activity,

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUIStore } from '../stores/ui.store'; import { useUIStore } from '../stores/ui.store';
import { type SupportedLanguage } from '../locales'; import { type SupportedLanguage } from '../locales';
@@ -10,18 +10,20 @@ import { type SupportedLanguage } from '../locales';
export function useLanguageSwitcher() { export function useLanguageSwitcher() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const { language: uiLanguage, setLanguage: setUILanguage } = useUIStore(); const { language: uiLanguage, setLanguage: setUILanguage } = useUIStore();
const [isChanging, setIsChanging] = useState(false);
const changeLanguage = useCallback(async (newLanguage: SupportedLanguage) => { const changeLanguage = useCallback(async (newLanguage: SupportedLanguage) => {
try { try {
setIsChanging(true);
// Only change i18n language - let the i18n event handler update UI store // Only change i18n language - let the i18n event handler update UI store
await i18n.changeLanguage(newLanguage); await i18n.changeLanguage(newLanguage);
setIsChanging(false);
} catch (error) { } catch (error) {
console.error('Failed to change language:', error); console.error('Failed to change language:', error);
setIsChanging(false);
} }
}, [i18n]); }, [i18n]);
const isChanging = i18n.isLanguageChangingTo !== false;
return { return {
currentLanguage: i18n.language as SupportedLanguage, currentLanguage: i18n.language as SupportedLanguage,
changeLanguage, changeLanguage,

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -12,12 +12,25 @@ import productionEs from './es/production.json';
// English translations // English translations
import commonEn from './en/common.json'; import commonEn from './en/common.json';
import authEn from './en/auth.json';
import inventoryEn from './en/inventory.json';
import foodSafetyEn from './en/foodSafety.json';
import suppliersEn from './en/suppliers.json';
import ordersEn from './en/orders.json';
import recipesEn from './en/recipes.json'; import recipesEn from './en/recipes.json';
import errorsEn from './en/errors.json';
import dashboardEn from './en/dashboard.json'; import dashboardEn from './en/dashboard.json';
import productionEn from './en/production.json'; import productionEn from './en/production.json';
// Basque translations // Basque translations
import commonEu from './eu/common.json'; import commonEu from './eu/common.json';
import authEu from './eu/auth.json';
import inventoryEu from './eu/inventory.json';
import foodSafetyEu from './eu/foodSafety.json';
import suppliersEu from './eu/suppliers.json';
import ordersEu from './eu/orders.json';
import recipesEu from './eu/recipes.json';
import errorsEu from './eu/errors.json';
import dashboardEu from './eu/dashboard.json'; import dashboardEu from './eu/dashboard.json';
import productionEu from './eu/production.json'; import productionEu from './eu/production.json';
@@ -37,12 +50,25 @@ export const resources = {
}, },
en: { en: {
common: commonEn, common: commonEn,
auth: authEn,
inventory: inventoryEn,
foodSafety: foodSafetyEn,
suppliers: suppliersEn,
orders: ordersEn,
recipes: recipesEn, recipes: recipesEn,
errors: errorsEn,
dashboard: dashboardEn, dashboard: dashboardEn,
production: productionEn, production: productionEn,
}, },
eu: { eu: {
common: commonEu, common: commonEu,
auth: authEu,
inventory: inventoryEu,
foodSafety: foodSafetyEu,
suppliers: suppliersEu,
orders: ordersEu,
recipes: recipesEu,
errors: errorsEu,
dashboard: dashboardEu, dashboard: dashboardEu,
production: productionEu, production: productionEu,
}, },

View File

@@ -12,7 +12,7 @@ import { useTenant } from '../../stores/tenant.store';
import { import {
AlertTriangle, AlertTriangle,
Clock, Clock,
DollarSign, Euro,
Package, Package,
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
@@ -33,7 +33,7 @@ const DashboardPage: React.FC = () => {
{ {
title: t('dashboard:stats.sales_today', 'Sales Today'), title: t('dashboard:stats.sales_today', 'Sales Today'),
value: '€1,247', value: '€1,247',
icon: DollarSign, icon: Euro,
variant: 'success' as const, variant: 'success' as const,
trend: { trend: {
value: 12, value: 12,

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter, Eye, Users, Package, CreditCard, BarChart3, AlertTriangle, Clock } from 'lucide-react'; import { Calendar, TrendingUp, Euro, ShoppingCart, Download, Filter, Eye, Users, Package, CreditCard, BarChart3, AlertTriangle, Clock } from 'lucide-react';
import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui'; import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { LoadingSpinner } from '../../../../components/shared'; import { LoadingSpinner } from '../../../../components/shared';
@@ -430,7 +430,7 @@ const SalesAnalyticsPage: React.FC = () => {
title: 'Ingresos Totales', title: 'Ingresos Totales',
value: formatters.currency(salesMetrics.totalRevenue), value: formatters.currency(salesMetrics.totalRevenue),
variant: 'success' as const, variant: 'success' as const,
icon: DollarSign, icon: Euro,
}, },
{ {
title: 'Total Transacciones', title: 'Total Transacciones',
@@ -711,7 +711,7 @@ const SalesAnalyticsPage: React.FC = () => {
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<DollarSign className="w-3 h-3 text-[var(--color-success)]" /> <Euro className="w-3 h-3 text-[var(--color-success)]" />
<span className="text-xs text-[var(--text-tertiary)]">#{index + 1}</span> <span className="text-xs text-[var(--text-tertiary)]">#{index + 1}</span>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play } from 'lucide-react'; import { Plus, ShoppingCart, Truck, Euro, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
@@ -22,6 +22,23 @@ const ProcurementPage: React.FC = () => {
const [editFormData, setEditFormData] = useState<any>({}); const [editFormData, setEditFormData] = useState<any>({});
const [selectedPlanForRequirements, setSelectedPlanForRequirements] = useState<string | null>(null); const [selectedPlanForRequirements, setSelectedPlanForRequirements] = useState<string | null>(null);
const [showCriticalRequirements, setShowCriticalRequirements] = useState(false); const [showCriticalRequirements, setShowCriticalRequirements] = useState(false);
const [showGeneratePlanModal, setShowGeneratePlanModal] = useState(false);
const [showRequirementDetailsModal, setShowRequirementDetailsModal] = useState(false);
const [selectedRequirement, setSelectedRequirement] = useState<any>(null);
const [generatePlanForm, setGeneratePlanForm] = useState({
plan_date: new Date().toISOString().split('T')[0],
planning_horizon_days: 14,
include_safety_stock: true,
safety_stock_percentage: 20,
force_regenerate: false
});
// Requirement details functionality
const handleViewRequirementDetails = (requirement: any) => {
setSelectedRequirement(requirement);
setShowRequirementDetailsModal(true);
};
const { currentTenant } = useTenantStore(); const { currentTenant } = useTenantStore();
const tenantId = currentTenant?.id || ''; const tenantId = currentTenant?.id || '';
@@ -55,13 +72,6 @@ const ProcurementPage: React.FC = () => {
return isLowStock || isNearDeadline || hasHighPriority; return isLowStock || isNearDeadline || hasHighPriority;
}); });
// Debug logging
console.log('📊 Plan Requirements Debug:', {
selectedPlanId: selectedPlanForRequirements,
allRequirements: allPlanRequirements?.length || 0,
criticalRequirements: planRequirements?.length || 0,
sampleRequirement: allPlanRequirements?.[0]
});
const generatePlanMutation = useGenerateProcurementPlan(); const generatePlanMutation = useGenerateProcurementPlan();
const updatePlanStatusMutation = useUpdateProcurementPlanStatus(); const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
@@ -123,7 +133,6 @@ const ProcurementPage: React.FC = () => {
const handleSaveEdit = () => { const handleSaveEdit = () => {
// For now, we'll just update the special requirements since that's the main editable field // For now, we'll just update the special requirements since that's the main editable field
// In a real implementation, you might have a separate API endpoint for updating plan details // In a real implementation, you might have a separate API endpoint for updating plan details
console.log('Saving plan edits:', editFormData);
setEditingPlan(null); setEditingPlan(null);
setEditFormData({}); setEditFormData({});
// Here you would typically call an update API // Here you would typically call an update API
@@ -135,7 +144,6 @@ const ProcurementPage: React.FC = () => {
}; };
const handleShowCriticalRequirements = (planId: string) => { const handleShowCriticalRequirements = (planId: string) => {
console.log('🔍 Opening critical requirements for plan:', planId);
setSelectedPlanForRequirements(planId); setSelectedPlanForRequirements(planId);
setShowCriticalRequirements(true); setShowCriticalRequirements(true);
}; };
@@ -191,6 +199,7 @@ const ProcurementPage: React.FC = () => {
return matchesSearch; return matchesSearch;
}) || []; }) || [];
const stats = { const stats = {
totalPlans: dashboardData?.summary?.total_plans || 0, totalPlans: dashboardData?.summary?.total_plans || 0,
activePlans: dashboardData?.summary?.active_plans || 0, activePlans: dashboardData?.summary?.active_plans || 0,
@@ -229,13 +238,13 @@ const ProcurementPage: React.FC = () => {
title: 'Costo Estimado', title: 'Costo Estimado',
value: formatters.currency(stats.totalEstimatedCost), value: formatters.currency(stats.totalEstimatedCost),
variant: 'info' as const, variant: 'info' as const,
icon: DollarSign, icon: Euro,
}, },
{ {
title: 'Costo Aprobado', title: 'Costo Aprobado',
value: formatters.currency(stats.totalApprovedCost), value: formatters.currency(stats.totalApprovedCost),
variant: 'success' as const, variant: 'success' as const,
icon: DollarSign, icon: Euro,
}, },
]; ];
@@ -245,27 +254,12 @@ const ProcurementPage: React.FC = () => {
title="Planificación de Compras" title="Planificación de Compras"
description="Administra planes de compras, requerimientos y análisis de procurement" description="Administra planes de compras, requerimientos y análisis de procurement"
actions={[ actions={[
{
id: "export",
label: "Exportar",
variant: "outline" as const,
icon: Download,
onClick: () => console.log('Export procurement data')
},
{ {
id: "generate", id: "generate",
label: "Generar Plan", label: "Generar Plan",
variant: "primary" as const, variant: "primary" as const,
icon: Plus, icon: Plus,
onClick: () => generatePlanMutation.mutate({ onClick: () => setShowGeneratePlanModal(true)
tenantId,
request: {
force_regenerate: false,
planning_horizon_days: 14,
include_safety_stock: true,
safety_stock_percentage: 20
}
})
}, },
{ {
id: "trigger", id: "trigger",
@@ -275,7 +269,7 @@ const ProcurementPage: React.FC = () => {
onClick: () => { onClick: () => {
triggerSchedulerMutation.mutate(tenantId, { triggerSchedulerMutation.mutate(tenantId, {
onSuccess: (data) => { onSuccess: (data) => {
console.log('✅ Scheduler ejecutado exitosamente:', data.message); // Scheduler executed successfully
// Show success notification (if you have a notification system) // Show success notification (if you have a notification system)
// toast.success(data.message); // toast.success(data.message);
}, },
@@ -314,10 +308,6 @@ const ProcurementPage: React.FC = () => {
className="w-full" className="w-full"
/> />
</div> </div>
<Button variant="outline" onClick={() => console.log('Export filtered')}>
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div> </div>
</Card> </Card>
@@ -610,7 +600,7 @@ const ProcurementPage: React.FC = () => {
icon: Eye, icon: Eye,
variant: 'primary', variant: 'primary',
priority: 'primary', priority: 'primary',
onClick: () => console.log('View requirement details', requirement) onClick: () => handleViewRequirementDetails(requirement)
}, },
...(requirement.purchase_order_number ? [ ...(requirement.purchase_order_number ? [
{ {
@@ -618,7 +608,9 @@ const ProcurementPage: React.FC = () => {
icon: Eye, icon: Eye,
variant: 'outline' as const, variant: 'outline' as const,
priority: 'secondary' as const, priority: 'secondary' as const,
onClick: () => console.log('View PO', requirement.purchase_order_number) onClick: () => {
// TODO: Open purchase order details
}
} }
] : [ ] : [
{ {
@@ -626,7 +618,9 @@ const ProcurementPage: React.FC = () => {
icon: Plus, icon: Plus,
variant: 'outline' as const, variant: 'outline' as const,
priority: 'secondary' as const, priority: 'secondary' as const,
onClick: () => console.log('Create PO for', requirement) onClick: () => {
// TODO: Create purchase order for requirement
}
} }
]), ]),
{ {
@@ -634,7 +628,9 @@ const ProcurementPage: React.FC = () => {
icon: Building2, icon: Building2,
variant: 'outline' as const, variant: 'outline' as const,
priority: 'secondary' as const, priority: 'secondary' as const,
onClick: () => console.log('Assign supplier') onClick: () => {
// TODO: Open supplier assignment modal
}
} }
]} ]}
/> />
@@ -724,7 +720,7 @@ const ProcurementPage: React.FC = () => {
}, },
{ {
title: 'Información Financiera', title: 'Información Financiera',
icon: DollarSign, icon: Euro,
fields: [ fields: [
{ {
label: 'Costo Estimado Total', label: 'Costo Estimado Total',
@@ -780,10 +776,412 @@ const ProcurementPage: React.FC = () => {
}] : []) }] : [])
]} ]}
onEdit={() => { onEdit={() => {
console.log('Editing procurement plan:', selectedPlan.id); // TODO: Implement plan editing functionality
}} }}
/> />
)} )}
{/* Generate Plan Modal */}
{showGeneratePlanModal && (
<div className="fixed top-[var(--header-height)] left-0 right-0 bottom-0 bg-black bg-opacity-50 flex items-center justify-center z-40">
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100">
<Plus className="w-5 h-5 text-blue-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
Generar Plan de Compras
</h2>
<p className="text-sm text-[var(--text-secondary)]">
Configura los parámetros para generar un nuevo plan
</p>
</div>
</div>
<Button
variant="outline"
onClick={() => setShowGeneratePlanModal(false)}
className="p-2"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
<div className="space-y-6">
{/* Plan Date */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Fecha del Plan
</label>
<Input
type="date"
value={generatePlanForm.plan_date}
onChange={(e) => setGeneratePlanForm(prev => ({ ...prev, plan_date: e.target.value }))}
className="w-full"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Fecha para la cual se generará el plan de compras
</p>
</div>
{/* Planning Horizon */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Horizonte de Planificación (días)
</label>
<Input
type="number"
min="1"
max="365"
value={generatePlanForm.planning_horizon_days}
onChange={(e) => setGeneratePlanForm(prev => ({ ...prev, planning_horizon_days: parseInt(e.target.value) }))}
className="w-full"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Número de días a considerar en la planificación (1-365)
</p>
</div>
{/* Safety Stock */}
<div className="space-y-4">
<div className="flex items-center space-x-3">
<input
type="checkbox"
id="include_safety_stock"
checked={generatePlanForm.include_safety_stock}
onChange={(e) => setGeneratePlanForm(prev => ({ ...prev, include_safety_stock: e.target.checked }))}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor="include_safety_stock" className="text-sm font-medium text-[var(--text-secondary)]">
Incluir Stock de Seguridad
</label>
</div>
{generatePlanForm.include_safety_stock && (
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
Porcentaje de Stock de Seguridad (%)
</label>
<Input
type="number"
min="0"
max="100"
value={generatePlanForm.safety_stock_percentage}
onChange={(e) => setGeneratePlanForm(prev => ({ ...prev, safety_stock_percentage: parseInt(e.target.value) }))}
className="w-full"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Porcentaje adicional para stock de seguridad (0-100%)
</p>
</div>
)}
</div>
{/* Force Regenerate */}
<div className="flex items-center space-x-3">
<input
type="checkbox"
id="force_regenerate"
checked={generatePlanForm.force_regenerate}
onChange={(e) => setGeneratePlanForm(prev => ({ ...prev, force_regenerate: e.target.checked }))}
className="w-4 h-4 text-red-600 bg-gray-100 border-gray-300 rounded focus:ring-red-500"
/>
<label htmlFor="force_regenerate" className="text-sm font-medium text-[var(--text-secondary)]">
Forzar Regeneración
</label>
</div>
<p className="text-xs text-[var(--text-tertiary)] ml-7">
Si ya existe un plan para esta fecha, regenerarlo (esto eliminará el plan existente)
</p>
</div>
</div>
{/* Footer */}
<div className="flex justify-end space-x-3 p-6 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={() => setShowGeneratePlanModal(false)}
disabled={generatePlanMutation.isPending}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={() => {
generatePlanMutation.mutate({
tenantId,
request: {
plan_date: generatePlanForm.plan_date,
planning_horizon_days: generatePlanForm.planning_horizon_days,
include_safety_stock: generatePlanForm.include_safety_stock,
safety_stock_percentage: generatePlanForm.safety_stock_percentage,
force_regenerate: generatePlanForm.force_regenerate
}
}, {
onSuccess: () => {
setShowGeneratePlanModal(false);
// Reset form to defaults
setGeneratePlanForm({
plan_date: new Date().toISOString().split('T')[0],
planning_horizon_days: 14,
include_safety_stock: true,
safety_stock_percentage: 20,
force_regenerate: false
});
}
});
}}
disabled={generatePlanMutation.isPending}
>
{generatePlanMutation.isPending ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
Generando...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Generar Plan
</>
)}
</Button>
</div>
</div>
</div>
)}
{/* Requirement Details Modal */}
{showRequirementDetailsModal && selectedRequirement && (
<div className="fixed top-[var(--header-height)] left-0 right-0 bottom-0 bg-black bg-opacity-50 flex items-center justify-center z-40">
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100">
<Eye className="w-5 h-5 text-blue-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
Detalles del Requerimiento
</h2>
<p className="text-sm text-[var(--text-secondary)]">
{selectedRequirement.product_name}
</p>
</div>
</div>
<Button
variant="outline"
onClick={() => setShowRequirementDetailsModal(false)}
className="p-2"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Product Information */}
<div className="space-y-4">
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
Información del Producto
</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Nombre</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.product_name}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">SKU</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.product_sku || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Categoría</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.product_category || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Tipo</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.product_type}</p>
</div>
</div>
</div>
{/* Quantities */}
<div className="space-y-4">
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
Cantidades
</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Cantidad Requerida</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.required_quantity} {selectedRequirement.unit_of_measure}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Stock de Seguridad</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.safety_stock_quantity} {selectedRequirement.unit_of_measure}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Stock Actual</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.current_stock_level} {selectedRequirement.unit_of_measure}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Requerimiento Neto</label>
<p className="text-[var(--text-primary)] font-semibold">{selectedRequirement.net_requirement} {selectedRequirement.unit_of_measure}</p>
</div>
</div>
</div>
{/* Costs */}
<div className="space-y-4">
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
Costos
</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Costo Unitario Estimado</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.estimated_unit_cost || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Costo Total Estimado</label>
<p className="text-[var(--text-primary)] font-semibold">{selectedRequirement.estimated_total_cost || 'N/A'}</p>
</div>
{selectedRequirement.last_purchase_cost && (
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Último Precio de Compra</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.last_purchase_cost}</p>
</div>
)}
</div>
</div>
{/* Dates & Timeline */}
<div className="space-y-4">
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
Fechas
</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Requerido Para</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.required_by_date}</p>
</div>
{selectedRequirement.suggested_order_date && (
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Fecha Sugerida de Pedido</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.suggested_order_date}</p>
</div>
)}
{selectedRequirement.latest_order_date && (
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Fecha Límite de Pedido</label>
<p className="text-[var(--text-primary)] text-red-600">{selectedRequirement.latest_order_date}</p>
</div>
)}
</div>
</div>
{/* Status & Priority */}
<div className="space-y-4">
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
Estado y Prioridad
</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Estado</label>
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
selectedRequirement.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
selectedRequirement.status === 'approved' ? 'bg-green-100 text-green-800' :
selectedRequirement.status === 'ordered' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{selectedRequirement.status}
</span>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Prioridad</label>
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
selectedRequirement.priority === 'critical' ? 'bg-red-100 text-red-800' :
selectedRequirement.priority === 'high' ? 'bg-orange-100 text-orange-800' :
'bg-green-100 text-green-800'
}`}>
{selectedRequirement.priority}
</span>
</div>
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Nivel de Riesgo</label>
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
selectedRequirement.risk_level === 'high' ? 'bg-red-100 text-red-800' :
selectedRequirement.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{selectedRequirement.risk_level}
</span>
</div>
</div>
</div>
{/* Supplier Information */}
{selectedRequirement.supplier_name && (
<div className="space-y-4">
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
Proveedor
</h3>
<div className="space-y-2">
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Nombre</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.supplier_name}</p>
</div>
{selectedRequirement.supplier_lead_time_days && (
<div>
<label className="text-sm font-medium text-[var(--text-secondary)]">Tiempo de Entrega</label>
<p className="text-[var(--text-primary)]">{selectedRequirement.supplier_lead_time_days} días</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Special Requirements */}
{selectedRequirement.special_requirements && (
<div className="mt-6 space-y-2">
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
Requerimientos Especiales
</h3>
<p className="text-[var(--text-primary)] bg-gray-50 p-3 rounded-lg">
{selectedRequirement.special_requirements}
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end space-x-3 p-6 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={() => setShowRequirementDetailsModal(false)}
>
Cerrar
</Button>
{selectedRequirement.status === 'pending' && (
<Button
variant="primary"
onClick={() => {
// TODO: Implement approval functionality
setShowRequirementDetailsModal(false);
}}
>
<CheckCircle className="w-4 h-4 mr-2" />
Aprobar
</Button>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Plus, Star, Clock, DollarSign, Package, Eye, Edit, ChefHat, Timer, Euro } from 'lucide-react'; import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/shared'; import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { formatters } from '../../../../components/ui/Stats/StatsPresets';
@@ -321,7 +321,7 @@ const RecipesPage: React.FC = () => {
}, },
{ {
title: 'Análisis Financiero', title: 'Análisis Financiero',
icon: DollarSign, icon: Euro,
fields: [ fields: [
{ {
label: 'Costo estimado por unidad', label: 'Costo estimado por unidad',

View File

@@ -16,7 +16,7 @@ import {
Play, Play,
Calendar, Calendar,
Clock, Clock,
DollarSign, Euro,
Package, Package,
PieChart, PieChart,
Settings Settings
@@ -248,7 +248,7 @@ const LandingPage: React.FC = () => {
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]"> <div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
<div className="w-12 h-12 bg-[var(--color-secondary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 bg-[var(--color-secondary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
<DollarSign className="w-6 h-6 text-[var(--color-secondary)]" /> <Euro className="w-6 h-6 text-[var(--color-secondary)]" />
</div> </div>
<h4 className="font-semibold text-[var(--text-primary)]">POS Integrado</h4> <h4 className="font-semibold text-[var(--text-primary)]">POS Integrado</h4>
<p className="text-sm text-[var(--text-secondary)] mt-2">Sistema de ventas completo y fácil de usar</p> <p className="text-sm text-[var(--text-secondary)] mt-2">Sistema de ventas completo y fácil de usar</p>

View File

@@ -8,8 +8,11 @@ Procurement API Endpoints - RESTful APIs for procurement planning
import uuid import uuid
from datetime import date from datetime import date
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
from app.core.database import get_db from app.core.database import get_db
from app.core.config import settings from app.core.config import settings
@@ -131,12 +134,12 @@ async def get_procurement_plan_by_date(
@monitor_performance("list_procurement_plans") @monitor_performance("list_procurement_plans")
async def list_procurement_plans( async def list_procurement_plans(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by plan status"), plan_status: Optional[str] = Query(None, description="Filter by plan status"),
start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"), start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"), end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"),
limit: int = Query(50, ge=1, le=100, description="Number of plans to return"), limit: int = Query(50, ge=1, le=100, description="Number of plans to return"),
offset: int = Query(0, ge=0, description="Number of plans to skip"), offset: int = Query(0, ge=0, description="Number of plans to skip"),
tenant_access: TenantAccess = Depends(get_current_tenant), # tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
""" """
@@ -147,8 +150,8 @@ async def list_procurement_plans(
try: try:
# Get plans from repository directly for listing # Get plans from repository directly for listing
plans = await procurement_service.plan_repo.list_plans( plans = await procurement_service.plan_repo.list_plans(
tenant_access.tenant_id, tenant_id,
status=status, status=plan_status,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
limit=limit, limit=limit,
@@ -156,7 +159,14 @@ async def list_procurement_plans(
) )
# Convert to response models # Convert to response models
plan_responses = [ProcurementPlanResponse.model_validate(plan) for plan in plans] plan_responses = []
for plan in plans:
try:
plan_response = ProcurementPlanResponse.model_validate(plan)
plan_responses.append(plan_response)
except Exception as validation_error:
logger.error(f"Error validating plan {plan.id}: {validation_error}")
raise
# For simplicity, we'll use the returned count as total # For simplicity, we'll use the returned count as total
# In a production system, you'd want a separate count query # In a production system, you'd want a separate count query
@@ -404,24 +414,33 @@ async def get_critical_requirements(
@monitor_performance("trigger_daily_scheduler") @monitor_performance("trigger_daily_scheduler")
async def trigger_daily_scheduler( async def trigger_daily_scheduler(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant), request: Request
procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
""" """
Manually trigger the daily scheduler for the current tenant Manually trigger the daily scheduler for the current tenant
This endpoint is primarily for testing and maintenance purposes. This endpoint is primarily for testing and maintenance purposes.
Note: Authentication temporarily disabled for development testing.
""" """
try: try:
# Process daily plan for current tenant only # Get the scheduler service from app state and call process_tenant_procurement
await procurement_service._process_daily_plan_for_tenant(tenant_access.tenant_id) if hasattr(request.app.state, 'scheduler_service'):
scheduler_service = request.app.state.scheduler_service
await scheduler_service.process_tenant_procurement(tenant_id)
return { return {
"success": True, "success": True,
"message": "Daily scheduler executed successfully", "message": "Daily scheduler executed successfully for tenant",
"tenant_id": str(tenant_access.tenant_id) "tenant_id": str(tenant_id)
} }
else:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scheduler service is not available"
)
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -429,6 +448,7 @@ async def trigger_daily_scheduler(
) )
@router.get("/procurement/health") @router.get("/procurement/health")
async def procurement_health_check(): async def procurement_health_check():
""" """

View File

@@ -68,5 +68,6 @@ class OrdersSettings(BaseServiceSettings):
SALES_SERVICE_URL: str = os.getenv("SALES_SERVICE_URL", "http://sales-service:8000") SALES_SERVICE_URL: str = os.getenv("SALES_SERVICE_URL", "http://sales-service:8000")
# Global settings instance # Global settings instance
settings = OrdersSettings() settings = OrdersSettings()

View File

@@ -228,7 +228,7 @@ class ProcurementRequirementBase(BaseModel):
product_name: str = Field(..., min_length=1, max_length=200) product_name: str = Field(..., min_length=1, max_length=200)
product_sku: Optional[str] = Field(None, max_length=100) product_sku: Optional[str] = Field(None, max_length=100)
product_category: Optional[str] = Field(None, max_length=100) product_category: Optional[str] = Field(None, max_length=100)
product_type: str = Field(default="ingredient") # TODO: Create ProductType enum if needed product_type: str = Field(default="ingredient")
required_quantity: Decimal = Field(..., gt=0) required_quantity: Decimal = Field(..., gt=0)
unit_of_measure: str = Field(..., min_length=1, max_length=50) unit_of_measure: str = Field(..., min_length=1, max_length=50)
safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0) safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0)

View File

@@ -143,11 +143,11 @@ class ProcurementPlanBase(ProcurementBase):
plan_period_end: date plan_period_end: date
planning_horizon_days: int = Field(default=14, gt=0) planning_horizon_days: int = Field(default=14, gt=0)
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal)$") plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal|urgent)$")
priority: str = Field(default="normal", pattern="^(high|normal|low)$") priority: str = Field(default="normal", pattern="^(high|normal|low)$")
business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$") business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$")
procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed)$") procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed|bulk_order)$")
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100) safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
supply_risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$") supply_risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$")

View File

@@ -31,6 +31,10 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
# Initialize base alert service # Initialize base alert service
await super().start() await super().start()
# Initialize procurement service instance for reuse
from app.core.database import AsyncSessionLocal
self.db_session_factory = AsyncSessionLocal
logger.info("Procurement scheduler service started", service=self.config.SERVICE_NAME) logger.info("Procurement scheduler service started", service=self.config.SERVICE_NAME)
def setup_scheduled_checks(self): def setup_scheduled_checks(self):
@@ -46,6 +50,20 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
max_instances=1 max_instances=1
) )
# Also add a test job that runs every 30 minutes for development/testing
# This will be disabled in production via environment variable
if getattr(self.config, 'DEBUG', False) or getattr(self.config, 'PROCUREMENT_TEST_MODE', False):
self.scheduler.add_job(
func=self.run_daily_procurement_planning,
trigger=CronTrigger(minute='*/30'), # Every 30 minutes
id="test_procurement_planning",
name="Test Procurement Planning (30min)",
misfire_grace_time=300,
coalesce=True,
max_instances=1
)
logger.info("⚡ Test procurement planning job added (every 30 minutes)")
# Weekly procurement optimization at 7:00 AM on Mondays # Weekly procurement optimization at 7:00 AM on Mondays
self.scheduler.add_job( self.scheduler.add_job(
func=self.run_weekly_optimization, func=self.run_weekly_optimization,
@@ -57,7 +75,8 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
max_instances=1 max_instances=1
) )
logger.info("Procurement scheduled jobs configured") logger.info("📅 Procurement scheduled jobs configured",
jobs_count=len(self.scheduler.get_jobs()))
async def run_daily_procurement_planning(self): async def run_daily_procurement_planning(self):
"""Execute daily procurement planning for all active tenants""" """Execute daily procurement planning for all active tenants"""
@@ -67,9 +86,10 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
try: try:
self._checks_performed += 1 self._checks_performed += 1
logger.info("Starting daily procurement planning") logger.info("🔄 Starting daily procurement planning execution",
timestamp=datetime.now().isoformat())
# Get active tenants # Get active tenants from tenant service
active_tenants = await self.get_active_tenants() active_tenants = await self.get_active_tenants()
if not active_tenants: if not active_tenants:
logger.info("No active tenants found for procurement planning") logger.info("No active tenants found for procurement planning")
@@ -77,27 +97,36 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
# Process each tenant # Process each tenant
processed_tenants = 0 processed_tenants = 0
failed_tenants = 0
for tenant_id in active_tenants: for tenant_id in active_tenants:
try: try:
logger.info("Processing tenant procurement", tenant_id=str(tenant_id))
await self.process_tenant_procurement(tenant_id) await self.process_tenant_procurement(tenant_id)
processed_tenants += 1 processed_tenants += 1
logger.info("✅ Successfully processed tenant", tenant_id=str(tenant_id))
except Exception as e: except Exception as e:
logger.error("Error processing tenant procurement", failed_tenants += 1
logger.error("❌ Error processing tenant procurement",
tenant_id=str(tenant_id), tenant_id=str(tenant_id),
error=str(e)) error=str(e))
logger.info("Daily procurement planning completed", logger.info("🎯 Daily procurement planning completed",
total_tenants=len(active_tenants), total_tenants=len(active_tenants),
processed_tenants=processed_tenants) processed_tenants=processed_tenants,
failed_tenants=failed_tenants)
except Exception as e: except Exception as e:
self._errors_count += 1 self._errors_count += 1
logger.error("Daily procurement planning failed", error=str(e)) logger.error("💥 Daily procurement planning failed completely", error=str(e))
async def get_active_tenants(self) -> List[UUID]: async def get_active_tenants(self) -> List[UUID]:
"""Override to return test tenant since tenants table is not in orders DB""" """Get active tenants from tenant service or base implementation"""
# For testing, return the known test tenant # Only use tenant service, no fallbacks
return [UUID('c464fb3e-7af2-46e6-9e43-85318f34199a')] try:
return await super().get_active_tenants()
except Exception as e:
logger.error("Could not fetch tenants from base service", error=str(e))
return []
async def process_tenant_procurement(self, tenant_id: UUID): async def process_tenant_procurement(self, tenant_id: UUID):
"""Process procurement planning for a specific tenant""" """Process procurement planning for a specific tenant"""
@@ -108,6 +137,11 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
# Calculate planning date (tomorrow by default) # Calculate planning date (tomorrow by default)
planning_date = datetime.now().date() + timedelta(days=1) planning_date = datetime.now().date() + timedelta(days=1)
logger.info("Processing procurement for tenant",
tenant_id=str(tenant_id),
planning_date=str(planning_date),
planning_days=planning_days)
# Create procurement service instance and generate plan # Create procurement service instance and generate plan
from app.core.database import AsyncSessionLocal from app.core.database import AsyncSessionLocal
from app.schemas.procurement_schemas import GeneratePlanRequest from app.schemas.procurement_schemas import GeneratePlanRequest
@@ -122,9 +156,10 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
) )
if existing_plan: if existing_plan:
logger.debug("Procurement plan already exists", logger.info("📋 Procurement plan already exists, skipping",
tenant_id=str(tenant_id), tenant_id=str(tenant_id),
plan_date=str(planning_date)) plan_date=str(planning_date),
plan_id=str(existing_plan.id))
return return
# Generate procurement plan # Generate procurement plan
@@ -132,25 +167,35 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
plan_date=planning_date, plan_date=planning_date,
planning_horizon_days=planning_days, planning_horizon_days=planning_days,
include_safety_stock=True, include_safety_stock=True,
safety_stock_percentage=Decimal('20.0') safety_stock_percentage=Decimal('20.0'),
force_regenerate=False
) )
logger.info("📊 Generating procurement plan",
tenant_id=str(tenant_id),
request_params=str(request.model_dump()))
result = await procurement_service.generate_procurement_plan(tenant_id, request) result = await procurement_service.generate_procurement_plan(tenant_id, request)
plan = result.plan if result.success else None
if plan: if result.success and result.plan:
# Send notification about new plan # Send notification about new plan
await self.send_procurement_notification( await self.send_procurement_notification(
tenant_id, plan, "plan_created" tenant_id, result.plan, "plan_created"
) )
logger.info("Procurement plan created successfully", logger.info("🎉 Procurement plan created successfully",
tenant_id=str(tenant_id), tenant_id=str(tenant_id),
plan_id=str(plan.id), plan_id=str(result.plan.id),
plan_date=str(planning_date)) plan_date=str(planning_date),
total_requirements=result.plan.total_requirements)
else:
logger.warning("⚠️ Failed to generate procurement plan",
tenant_id=str(tenant_id),
errors=result.errors,
warnings=result.warnings)
except Exception as e: except Exception as e:
logger.error("Error processing tenant procurement", logger.error("💥 Error processing tenant procurement",
tenant_id=str(tenant_id), tenant_id=str(tenant_id),
error=str(e)) error=str(e))
raise raise
@@ -237,8 +282,14 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
error=str(e)) error=str(e))
async def test_procurement_generation(self): async def test_procurement_generation(self):
"""Test method to manually trigger procurement planning for testing""" """Test method to manually trigger procurement planning"""
test_tenant_id = UUID('c464fb3e-7af2-46e6-9e43-85318f34199a') # Get the first available tenant for testing
active_tenants = await self.get_active_tenants()
if not active_tenants:
logger.error("No active tenants found for testing procurement generation")
return
test_tenant_id = active_tenants[0]
logger.info("Testing procurement plan generation", tenant_id=str(test_tenant_id)) logger.info("Testing procurement plan generation", tenant_id=str(test_tenant_id))
try: try:

View File

@@ -165,13 +165,22 @@ class ProcurementService:
if requirements_data: if requirements_data:
await self.requirement_repo.create_requirements_batch(requirements_data) await self.requirement_repo.create_requirements_batch(requirements_data)
# Update plan with correct total_requirements count # Calculate total costs from requirements
await self.plan_repo.update_plan( total_estimated_cost = sum(
plan.id, req_data.get('estimated_total_cost', Decimal('0'))
tenant_id, for req_data in requirements_data
{"total_requirements": len(requirements_data)}
) )
# Update plan with correct totals
plan_updates = {
"total_requirements": len(requirements_data),
"total_estimated_cost": total_estimated_cost,
"total_approved_cost": Decimal('0'), # Will be updated during approval
"cost_variance": Decimal('0') - total_estimated_cost # Initial variance
}
await self.plan_repo.update_plan(plan.id, tenant_id, plan_updates)
await self.db.commit() await self.db.commit()
# Step 6: Cache the plan and publish event # Step 6: Cache the plan and publish event
@@ -213,6 +222,13 @@ class ProcurementService:
if status == "approved": if status == "approved":
updates["approved_at"] = datetime.utcnow() updates["approved_at"] = datetime.utcnow()
updates["approved_by"] = updated_by updates["approved_by"] = updated_by
# When approving, set approved cost equal to estimated cost
# (In real system, this might be different based on actual approvals)
plan = await self.plan_repo.get_plan_by_id(plan_id, tenant_id)
if plan and plan.total_estimated_cost:
updates["total_approved_cost"] = plan.total_estimated_cost
updates["cost_variance"] = Decimal('0') # No variance initially
elif status == "in_execution": elif status == "in_execution":
updates["execution_started_at"] = datetime.utcnow() updates["execution_started_at"] = datetime.utcnow()
elif status in ["completed", "cancelled"]: elif status in ["completed", "cancelled"]:
@@ -497,25 +513,168 @@ class ProcurementService:
async def _get_procurement_summary(self, tenant_id: uuid.UUID) -> ProcurementSummary: async def _get_procurement_summary(self, tenant_id: uuid.UUID) -> ProcurementSummary:
"""Get procurement summary for dashboard""" """Get procurement summary for dashboard"""
# Implement summary calculation try:
return ProcurementSummary( # Get all plans for the tenant
total_plans=0, all_plans = await self.plan_repo.list_plans(tenant_id, limit=1000)
active_plans=0,
total_requirements=0, # Debug logging
pending_requirements=0, logger.info(f"Found {len(all_plans)} plans for tenant {tenant_id}")
critical_requirements=0, for plan in all_plans[:3]: # Log first 3 plans for debugging
total_estimated_cost=Decimal('0'), logger.info(f"Plan {plan.plan_number}: status={plan.status}, requirements={plan.total_requirements}, cost={plan.total_estimated_cost}")
total_approved_cost=Decimal('0'),
cost_variance=Decimal('0') # Calculate total and active plans
) total_plans = len(all_plans)
active_statuses = ['draft', 'pending_approval', 'approved', 'in_execution']
active_plans = len([p for p in all_plans if p.status in active_statuses])
# Get all requirements for analysis
pending_requirements = []
critical_requirements = []
try:
pending_requirements = await self.requirement_repo.get_pending_requirements(tenant_id)
logger.info(f"Found {len(pending_requirements)} pending requirements")
except Exception as req_err:
logger.warning(f"Error getting pending requirements: {req_err}")
try:
critical_requirements = await self.requirement_repo.get_critical_requirements(tenant_id)
logger.info(f"Found {len(critical_requirements)} critical requirements")
except Exception as crit_err:
logger.warning(f"Error getting critical requirements: {crit_err}")
# Calculate total requirements across all plans
total_requirements = 0
total_estimated_cost = Decimal('0')
total_approved_cost = Decimal('0')
plans_to_fix = [] # Track plans that need recalculation
for plan in all_plans:
plan_reqs = plan.total_requirements or 0
plan_est_cost = plan.total_estimated_cost or Decimal('0')
plan_app_cost = plan.total_approved_cost or Decimal('0')
# If plan has requirements but zero costs, it needs recalculation
if plan_reqs > 0 and plan_est_cost == Decimal('0'):
plans_to_fix.append(plan.id)
logger.info(f"Plan {plan.plan_number} needs cost recalculation")
total_requirements += plan_reqs
total_estimated_cost += plan_est_cost
total_approved_cost += plan_app_cost
# Fix plans with missing totals (do this in background to avoid blocking dashboard)
if plans_to_fix:
logger.info(f"Found {len(plans_to_fix)} plans that need cost recalculation")
# For now, just log. In production, you might want to queue this for background processing
# Calculate cost variance
cost_variance = total_approved_cost - total_estimated_cost
logger.info(f"Summary totals: plans={total_plans}, active={active_plans}, requirements={total_requirements}, est_cost={total_estimated_cost}")
return ProcurementSummary(
total_plans=total_plans,
active_plans=active_plans,
total_requirements=total_requirements,
pending_requirements=len(pending_requirements),
critical_requirements=len(critical_requirements),
total_estimated_cost=total_estimated_cost,
total_approved_cost=total_approved_cost,
cost_variance=cost_variance
)
except Exception as e:
logger.error("Error calculating procurement summary", error=str(e), tenant_id=tenant_id)
# Return empty summary on error
return ProcurementSummary(
total_plans=0,
active_plans=0,
total_requirements=0,
pending_requirements=0,
critical_requirements=0,
total_estimated_cost=Decimal('0'),
total_approved_cost=Decimal('0'),
cost_variance=Decimal('0')
)
async def _get_upcoming_deliveries(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]: async def _get_upcoming_deliveries(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]:
"""Get upcoming deliveries""" """Get upcoming deliveries"""
return [] try:
# Get requirements with expected delivery dates in the next 7 days
today = date.today()
upcoming_date = today + timedelta(days=7)
# Get all pending requirements that have expected delivery dates
pending_requirements = await self.requirement_repo.get_pending_requirements(tenant_id)
upcoming_deliveries = []
for req in pending_requirements:
if (req.expected_delivery_date and
today <= req.expected_delivery_date <= upcoming_date and
req.delivery_status in ['pending', 'in_transit']):
upcoming_deliveries.append({
"id": str(req.id),
"requirement_number": req.requirement_number,
"product_name": req.product_name,
"supplier_name": req.supplier_name or "Sin proveedor",
"expected_delivery_date": req.expected_delivery_date.isoformat(),
"ordered_quantity": float(req.ordered_quantity or 0),
"unit_of_measure": req.unit_of_measure,
"delivery_status": req.delivery_status,
"days_until_delivery": (req.expected_delivery_date - today).days
})
# Sort by delivery date
upcoming_deliveries.sort(key=lambda x: x["expected_delivery_date"])
return upcoming_deliveries[:10] # Return top 10 upcoming deliveries
except Exception as e:
logger.error("Error getting upcoming deliveries", error=str(e), tenant_id=tenant_id)
return []
async def _get_overdue_requirements(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]: async def _get_overdue_requirements(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]:
"""Get overdue requirements""" """Get overdue requirements"""
return [] try:
today = date.today()
# Get all pending requirements
pending_requirements = await self.requirement_repo.get_pending_requirements(tenant_id)
overdue_requirements = []
for req in pending_requirements:
# Check if requirement is overdue based on required_by_date
if (req.required_by_date and req.required_by_date < today and
req.status in ['pending', 'approved']):
days_overdue = (today - req.required_by_date).days
overdue_requirements.append({
"id": str(req.id),
"requirement_number": req.requirement_number,
"product_name": req.product_name,
"supplier_name": req.supplier_name or "Sin proveedor",
"required_by_date": req.required_by_date.isoformat(),
"required_quantity": float(req.required_quantity),
"unit_of_measure": req.unit_of_measure,
"status": req.status,
"priority": req.priority,
"days_overdue": days_overdue,
"estimated_total_cost": float(req.estimated_total_cost or 0)
})
# Sort by days overdue (most overdue first)
overdue_requirements.sort(key=lambda x: x["days_overdue"], reverse=True)
return overdue_requirements[:10] # Return top 10 overdue requirements
except Exception as e:
logger.error("Error getting overdue requirements", error=str(e), tenant_id=tenant_id)
return []
async def _get_low_stock_alerts(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]: async def _get_low_stock_alerts(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]:
"""Get low stock alerts from inventory service""" """Get low stock alerts from inventory service"""
@@ -527,4 +686,63 @@ class ProcurementService:
async def _get_performance_metrics(self, tenant_id: uuid.UUID) -> Dict[str, Any]: async def _get_performance_metrics(self, tenant_id: uuid.UUID) -> Dict[str, Any]:
"""Get performance metrics""" """Get performance metrics"""
return {} try:
# Get completed and active plans for metrics calculation
all_plans = await self.plan_repo.list_plans(tenant_id, limit=1000)
completed_plans = [p for p in all_plans if p.status == 'completed']
if not completed_plans:
return {
"average_fulfillment_rate": 0.0,
"average_on_time_delivery": 0.0,
"cost_accuracy": 0.0,
"plan_completion_rate": 0.0,
"supplier_performance": 0.0
}
# Calculate fulfillment rate
total_fulfillment = sum(float(p.fulfillment_rate or 0) for p in completed_plans)
avg_fulfillment = total_fulfillment / len(completed_plans) if completed_plans else 0.0
# Calculate on-time delivery rate
total_on_time = sum(float(p.on_time_delivery_rate or 0) for p in completed_plans)
avg_on_time = total_on_time / len(completed_plans) if completed_plans else 0.0
# Calculate cost accuracy (how close approved costs were to estimated)
cost_accuracy_sum = 0.0
cost_plans_count = 0
for plan in completed_plans:
if plan.total_estimated_cost and plan.total_approved_cost and plan.total_estimated_cost > 0:
accuracy = min(100.0, (float(plan.total_approved_cost) / float(plan.total_estimated_cost)) * 100)
cost_accuracy_sum += accuracy
cost_plans_count += 1
avg_cost_accuracy = cost_accuracy_sum / cost_plans_count if cost_plans_count > 0 else 0.0
# Calculate plan completion rate
total_plans = len(all_plans)
completion_rate = (len(completed_plans) / total_plans * 100) if total_plans > 0 else 0.0
# Calculate supplier performance (average quality score)
quality_scores = [float(p.quality_score or 0) for p in completed_plans if p.quality_score]
avg_supplier_performance = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0
return {
"average_fulfillment_rate": round(avg_fulfillment, 2),
"average_on_time_delivery": round(avg_on_time, 2),
"cost_accuracy": round(avg_cost_accuracy, 2),
"plan_completion_rate": round(completion_rate, 2),
"supplier_performance": round(avg_supplier_performance, 2),
"total_plans_analyzed": len(completed_plans),
"active_plans": len([p for p in all_plans if p.status in ['draft', 'pending_approval', 'approved', 'in_execution']])
}
except Exception as e:
logger.error("Error calculating performance metrics", error=str(e), tenant_id=tenant_id)
return {
"average_fulfillment_rate": 0.0,
"average_on_time_delivery": 0.0,
"cost_accuracy": 0.0,
"plan_completion_rate": 0.0,
"supplier_performance": 0.0
}

View File

@@ -1,290 +0,0 @@
#!/usr/bin/env python3
"""
Script to populate the database with test data for orders and customers
"""
import os
import sys
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
import asyncio
import random
# Add the parent directory to the path to import our modules
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.customer import Customer, CustomerContact
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
# Test tenant ID - in a real environment this would be provided
TEST_TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
# Sample customer data
SAMPLE_CUSTOMERS = [
{
"name": "María García López",
"customer_type": "individual",
"email": "maria.garcia@email.com",
"phone": "+34 612 345 678",
"city": "Madrid",
"country": "España",
"customer_segment": "vip",
"is_active": True
},
{
"name": "Panadería San Juan",
"business_name": "Panadería San Juan S.L.",
"customer_type": "business",
"email": "pedidos@panaderiasjuan.com",
"phone": "+34 687 654 321",
"city": "Barcelona",
"country": "España",
"customer_segment": "wholesale",
"is_active": True
},
{
"name": "Carlos Rodríguez Martín",
"customer_type": "individual",
"email": "carlos.rodriguez@email.com",
"phone": "+34 698 765 432",
"city": "Valencia",
"country": "España",
"customer_segment": "regular",
"is_active": True
},
{
"name": "Ana Fernández Ruiz",
"customer_type": "individual",
"email": "ana.fernandez@email.com",
"phone": "+34 634 567 890",
"city": "Sevilla",
"country": "España",
"customer_segment": "regular",
"is_active": True
},
{
"name": "Café Central",
"business_name": "Café Central Madrid S.L.",
"customer_type": "business",
"email": "compras@cafecentral.es",
"phone": "+34 623 456 789",
"city": "Madrid",
"country": "España",
"customer_segment": "wholesale",
"is_active": True
},
{
"name": "Laura Martínez Silva",
"customer_type": "individual",
"email": "laura.martinez@email.com",
"phone": "+34 645 789 012",
"city": "Bilbao",
"country": "España",
"customer_segment": "regular",
"is_active": False # Inactive customer for testing
}
]
# Sample products (in a real system these would come from a products service)
SAMPLE_PRODUCTS = [
{"id": str(uuid.uuid4()), "name": "Pan Integral Artesano", "price": Decimal("2.50"), "category": "Panadería"},
{"id": str(uuid.uuid4()), "name": "Croissant de Mantequilla", "price": Decimal("1.80"), "category": "Bollería"},
{"id": str(uuid.uuid4()), "name": "Tarta de Santiago", "price": Decimal("18.90"), "category": "Repostería"},
{"id": str(uuid.uuid4()), "name": "Magdalenas de Limón", "price": Decimal("0.90"), "category": "Bollería"},
{"id": str(uuid.uuid4()), "name": "Empanada de Atún", "price": Decimal("3.50"), "category": "Salado"},
{"id": str(uuid.uuid4()), "name": "Brownie de Chocolate", "price": Decimal("3.20"), "category": "Repostería"},
{"id": str(uuid.uuid4()), "name": "Baguette Francesa", "price": Decimal("2.80"), "category": "Panadería"},
{"id": str(uuid.uuid4()), "name": "Palmera de Chocolate", "price": Decimal("2.40"), "category": "Bollería"},
]
async def create_customers(session: AsyncSession) -> list[Customer]:
"""Create sample customers"""
customers = []
for i, customer_data in enumerate(SAMPLE_CUSTOMERS):
customer = Customer(
tenant_id=TEST_TENANT_ID,
customer_code=f"CUST-{i+1:04d}",
name=customer_data["name"],
business_name=customer_data.get("business_name"),
customer_type=customer_data["customer_type"],
email=customer_data["email"],
phone=customer_data["phone"],
city=customer_data["city"],
country=customer_data["country"],
is_active=customer_data["is_active"],
preferred_delivery_method="delivery" if random.choice([True, False]) else "pickup",
payment_terms=random.choice(["immediate", "net_30"]),
customer_segment=customer_data["customer_segment"],
priority_level=random.choice(["normal", "high"]) if customer_data["customer_segment"] == "vip" else "normal",
discount_percentage=Decimal("5.0") if customer_data["customer_segment"] == "vip" else
Decimal("10.0") if customer_data["customer_segment"] == "wholesale" else Decimal("0.0"),
total_orders=random.randint(5, 50),
total_spent=Decimal(str(random.randint(100, 5000))),
average_order_value=Decimal(str(random.randint(15, 150))),
last_order_date=datetime.now() - timedelta(days=random.randint(1, 30))
)
session.add(customer)
customers.append(customer)
await session.commit()
return customers
async def create_orders(session: AsyncSession, customers: list[Customer]):
"""Create sample orders in different statuses"""
order_statuses = [
"pending", "confirmed", "in_production", "ready",
"out_for_delivery", "delivered", "cancelled"
]
order_types = ["standard", "rush", "recurring", "special"]
priorities = ["low", "normal", "high"]
delivery_methods = ["delivery", "pickup"]
payment_statuses = ["pending", "partial", "paid", "failed"]
for i in range(25): # Create 25 sample orders
customer = random.choice(customers)
order_status = random.choice(order_statuses)
# Create order date in the last 30 days
order_date = datetime.now() - timedelta(days=random.randint(0, 30))
# Create delivery date (1-7 days after order date)
delivery_date = order_date + timedelta(days=random.randint(1, 7))
order = CustomerOrder(
tenant_id=TEST_TENANT_ID,
order_number=f"ORD-{datetime.now().year}-{i+1:04d}",
customer_id=customer.id,
status=order_status,
order_type=random.choice(order_types),
priority=random.choice(priorities),
order_date=order_date,
requested_delivery_date=delivery_date,
confirmed_delivery_date=delivery_date if order_status not in ["pending", "cancelled"] else None,
actual_delivery_date=delivery_date if order_status == "delivered" else None,
delivery_method=random.choice(delivery_methods),
delivery_instructions=random.choice([
None, "Dejar en recepción", "Llamar al timbre", "Cuidado con el escalón"
]),
discount_percentage=customer.discount_percentage,
payment_status=random.choice(payment_statuses) if order_status != "cancelled" else "failed",
payment_method=random.choice(["cash", "card", "bank_transfer"]),
payment_terms=customer.payment_terms,
special_instructions=random.choice([
None, "Sin gluten", "Decoración especial", "Entrega temprano", "Cliente VIP"
]),
order_source=random.choice(["manual", "online", "phone"]),
sales_channel=random.choice(["direct", "wholesale"]),
customer_notified_confirmed=order_status not in ["pending", "cancelled"],
customer_notified_ready=order_status in ["ready", "out_for_delivery", "delivered"],
customer_notified_delivered=order_status == "delivered",
quality_score=Decimal(str(random.randint(70, 100) / 10)) if order_status == "delivered" else None,
customer_rating=random.randint(3, 5) if order_status == "delivered" else None
)
session.add(order)
await session.flush() # Flush to get the order ID
# Create order items
num_items = random.randint(1, 5)
subtotal = Decimal("0.00")
for _ in range(num_items):
product = random.choice(SAMPLE_PRODUCTS)
quantity = random.randint(1, 10)
unit_price = product["price"]
line_total = unit_price * quantity
order_item = OrderItem(
order_id=order.id,
product_id=product["id"],
product_name=product["name"],
product_category=product["category"],
quantity=quantity,
unit_of_measure="unidad",
unit_price=unit_price,
line_discount=Decimal("0.00"),
line_total=line_total,
status=order_status if order_status != "cancelled" else "cancelled"
)
session.add(order_item)
subtotal += line_total
# Calculate financial totals
discount_amount = subtotal * (order.discount_percentage / 100)
tax_amount = (subtotal - discount_amount) * Decimal("0.21") # 21% VAT
delivery_fee = Decimal("3.50") if order.delivery_method == "delivery" and subtotal < 25 else Decimal("0.00")
total_amount = subtotal - discount_amount + tax_amount + delivery_fee
# Update order with calculated totals
order.subtotal = subtotal
order.discount_amount = discount_amount
order.tax_amount = tax_amount
order.delivery_fee = delivery_fee
order.total_amount = total_amount
# Create status history
status_history = OrderStatusHistory(
order_id=order.id,
from_status=None,
to_status=order_status,
event_type="status_change",
event_description=f"Order created with status: {order_status}",
change_source="system",
changed_at=order_date,
customer_notified=order_status != "pending"
)
session.add(status_history)
# Add additional status changes for non-pending orders
if order_status != "pending":
current_date = order_date
for status in ["confirmed", "in_production", "ready"]:
if order_statuses.index(status) <= order_statuses.index(order_status):
current_date += timedelta(hours=random.randint(2, 12))
status_change = OrderStatusHistory(
order_id=order.id,
from_status="pending" if status == "confirmed" else None,
to_status=status,
event_type="status_change",
event_description=f"Order status changed to: {status}",
change_source="manual",
changed_at=current_date,
customer_notified=True
)
session.add(status_change)
await session.commit()
async def main():
"""Main function to seed the database"""
print("🌱 Starting database seeding...")
async for session in get_session():
try:
print("📋 Creating customers...")
customers = await create_customers(session)
print(f"✅ Created {len(customers)} customers")
print("📦 Creating orders...")
await create_orders(session, customers)
print("✅ Created orders with different statuses")
print("🎉 Database seeding completed successfully!")
except Exception as e:
print(f"❌ Error during seeding: {e}")
await session.rollback()
raise
finally:
await session.close()
if __name__ == "__main__":
asyncio.run(main())