Fix new services implementation 2

This commit is contained in:
Urtzi Alfaro
2025-08-14 13:26:59 +02:00
parent 262b3dc9c4
commit 0951547e92
39 changed files with 1203 additions and 917 deletions

View File

@@ -392,6 +392,57 @@ private buildURL(endpoint: string): string {
return this.request<T>(endpoint, { ...config, method: 'DELETE' }); return this.request<T>(endpoint, { ...config, method: 'DELETE' });
} }
/**
* Raw request that returns the Response object for binary data
*/
async getRaw(endpoint: string, config?: RequestConfig): Promise<Response> {
const url = this.buildURL(endpoint);
const modifiedConfig = await this.applyRequestInterceptors(config || {});
const headers: Record<string, string> = {
...modifiedConfig.headers,
};
const fetchConfig: RequestInit = {
method: 'GET',
headers,
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
};
// Add query parameters
const urlWithParams = new URL(url);
if (modifiedConfig.params) {
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlWithParams.searchParams.append(key, String(value));
}
});
}
const response = await fetch(urlWithParams.toString(), fetchConfig);
if (!response.ok) {
const errorText = await response.text();
let errorData: ApiError;
try {
errorData = JSON.parse(errorText);
} catch {
errorData = {
message: `HTTP ${response.status}: ${response.statusText}`,
detail: errorText,
code: `HTTP_${response.status}`,
};
}
const error = new Error(errorData.message || 'Request failed');
(error as any).response = { status: response.status, data: errorData };
throw error;
}
return response;
}
/** /**
* File upload with progress tracking * File upload with progress tracking
*/ */

View File

@@ -88,7 +88,7 @@ interface UseInventoryItemReturn {
// ========== MAIN INVENTORY HOOK ========== // ========== MAIN INVENTORY HOOK ==========
export const useInventory = (autoLoad = true): UseInventoryReturn => { export const useInventory = (autoLoad = true): UseInventoryReturn => {
const tenantId = useTenantId(); const { tenantId } = useTenantId();
// State // State
const [items, setItems] = useState<InventoryItem[]>([]); const [items, setItems] = useState<InventoryItem[]>([]);
@@ -373,7 +373,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
// ========== DASHBOARD HOOK ========== // ========== DASHBOARD HOOK ==========
export const useInventoryDashboard = (): UseInventoryDashboardReturn => { export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
const tenantId = useTenantId(); const { tenantId } = useTenantId();
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null); const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
const [alerts, setAlerts] = useState<StockAlert[]>([]); const [alerts, setAlerts] = useState<StockAlert[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -419,7 +419,7 @@ export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
// ========== SINGLE ITEM HOOK ========== // ========== SINGLE ITEM HOOK ==========
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => { export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
const tenantId = useTenantId(); const { tenantId } = useTenantId();
const [item, setItem] = useState<InventoryItem | null>(null); const [item, setItem] = useState<InventoryItem | null>(null);
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null); const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]); const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);

View File

@@ -22,6 +22,23 @@ import {
} from '../services/suppliers.service'; } from '../services/suppliers.service';
import { useAuth } from './useAuth'; import { useAuth } from './useAuth';
// Re-export types for component use
export type {
Supplier,
SupplierSummary,
CreateSupplierRequest,
UpdateSupplierRequest,
SupplierSearchParams,
SupplierStatistics,
PurchaseOrder,
CreatePurchaseOrderRequest,
PurchaseOrderSearchParams,
PurchaseOrderStatistics,
Delivery,
DeliverySearchParams,
DeliveryPerformanceStats
} from '../services/suppliers.service';
const suppliersService = new SuppliersService(); const suppliersService = new SuppliersService();
// ============================================================================ // ============================================================================
@@ -196,13 +213,13 @@ export function useSuppliers(): UseSuppliers {
// Create supplier // Create supplier
const createSupplier = useCallback(async (data: CreateSupplierRequest): Promise<Supplier | null> => { const createSupplier = useCallback(async (data: CreateSupplierRequest): Promise<Supplier | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
const supplier = await suppliersService.createSupplier(user.tenant_id, user.user_id, data); const supplier = await suppliersService.createSupplier(user.tenant_id, user.id, data);
// Refresh suppliers list // Refresh suppliers list
await loadSuppliers(currentParams); await loadSuppliers(currentParams);
@@ -217,17 +234,17 @@ export function useSuppliers(): UseSuppliers {
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
}, [user?.tenant_id, user?.user_id, loadSuppliers, loadStatistics, currentParams]); }, [user?.tenant_id, user?.id, loadSuppliers, loadStatistics, currentParams]);
// Update supplier // Update supplier
const updateSupplier = useCallback(async (supplierId: string, data: UpdateSupplierRequest): Promise<Supplier | null> => { const updateSupplier = useCallback(async (supplierId: string, data: UpdateSupplierRequest): Promise<Supplier | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setIsUpdating(true); setIsUpdating(true);
setError(null); setError(null);
const updatedSupplier = await suppliersService.updateSupplier(user.tenant_id, user.user_id, supplierId, data); const updatedSupplier = await suppliersService.updateSupplier(user.tenant_id, user.id, supplierId, data);
// Update current supplier if it's the one being edited // Update current supplier if it's the one being edited
if (supplier?.id === supplierId) { if (supplier?.id === supplierId) {
@@ -246,7 +263,7 @@ export function useSuppliers(): UseSuppliers {
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
}, [user?.tenant_id, user?.user_id, supplier?.id, loadSuppliers, currentParams]); }, [user?.tenant_id, user?.id, supplier?.id, loadSuppliers, currentParams]);
// Delete supplier // Delete supplier
const deleteSupplier = useCallback(async (supplierId: string): Promise<boolean> => { const deleteSupplier = useCallback(async (supplierId: string): Promise<boolean> => {
@@ -277,12 +294,12 @@ export function useSuppliers(): UseSuppliers {
// Approve/reject supplier // Approve/reject supplier
const approveSupplier = useCallback(async (supplierId: string, action: 'approve' | 'reject', notes?: string): Promise<Supplier | null> => { const approveSupplier = useCallback(async (supplierId: string, action: 'approve' | 'reject', notes?: string): Promise<Supplier | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setError(null); setError(null);
const updatedSupplier = await suppliersService.approveSupplier(user.tenant_id, user.user_id, supplierId, action, notes); const updatedSupplier = await suppliersService.approveSupplier(user.tenant_id, user.id, supplierId, action, notes);
// Update current supplier if it's the one being approved/rejected // Update current supplier if it's the one being approved/rejected
if (supplier?.id === supplierId) { if (supplier?.id === supplierId) {
@@ -300,7 +317,7 @@ export function useSuppliers(): UseSuppliers {
setError(errorMessage); setError(errorMessage);
return null; return null;
} }
}, [user?.tenant_id, user?.user_id, supplier?.id, loadSuppliers, loadStatistics, currentParams]); }, [user?.tenant_id, user?.id, supplier?.id, loadSuppliers, loadStatistics, currentParams]);
// Clear error // Clear error
const clearError = useCallback(() => { const clearError = useCallback(() => {
@@ -504,13 +521,13 @@ export function usePurchaseOrders(): UsePurchaseOrders {
}, [user?.tenant_id]); }, [user?.tenant_id]);
const createPurchaseOrder = useCallback(async (data: CreatePurchaseOrderRequest): Promise<PurchaseOrder | null> => { const createPurchaseOrder = useCallback(async (data: CreatePurchaseOrderRequest): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
const order = await suppliersService.createPurchaseOrder(user.tenant_id, user.user_id, data); const order = await suppliersService.createPurchaseOrder(user.tenant_id, user.id, data);
// Refresh orders list // Refresh orders list
await loadPurchaseOrders(currentParams); await loadPurchaseOrders(currentParams);
@@ -525,15 +542,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
}, [user?.tenant_id, user?.user_id, loadPurchaseOrders, loadStatistics, currentParams]); }, [user?.tenant_id, user?.id, loadPurchaseOrders, loadStatistics, currentParams]);
const updateOrderStatus = useCallback(async (poId: string, status: string, notes?: string): Promise<PurchaseOrder | null> => { const updateOrderStatus = useCallback(async (poId: string, status: string, notes?: string): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setError(null); setError(null);
const updatedOrder = await suppliersService.updatePurchaseOrderStatus(user.tenant_id, user.user_id, poId, status, notes); const updatedOrder = await suppliersService.updatePurchaseOrderStatus(user.tenant_id, user.id, poId, status, notes);
if (purchaseOrder?.id === poId) { if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder); setPurchaseOrder(updatedOrder);
@@ -548,15 +565,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
setError(errorMessage); setError(errorMessage);
return null; return null;
} }
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]); }, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
const approveOrder = useCallback(async (poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder | null> => { const approveOrder = useCallback(async (poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setError(null); setError(null);
const updatedOrder = await suppliersService.approvePurchaseOrder(user.tenant_id, user.user_id, poId, action, notes); const updatedOrder = await suppliersService.approvePurchaseOrder(user.tenant_id, user.id, poId, action, notes);
if (purchaseOrder?.id === poId) { if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder); setPurchaseOrder(updatedOrder);
@@ -572,15 +589,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
setError(errorMessage); setError(errorMessage);
return null; return null;
} }
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, loadOrdersRequiringApproval, currentParams]); }, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, loadOrdersRequiringApproval, currentParams]);
const sendToSupplier = useCallback(async (poId: string, sendEmail: boolean = true): Promise<PurchaseOrder | null> => { const sendToSupplier = useCallback(async (poId: string, sendEmail: boolean = true): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setError(null); setError(null);
const updatedOrder = await suppliersService.sendToSupplier(user.tenant_id, user.user_id, poId, sendEmail); const updatedOrder = await suppliersService.sendToSupplier(user.tenant_id, user.id, poId, sendEmail);
if (purchaseOrder?.id === poId) { if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder); setPurchaseOrder(updatedOrder);
@@ -595,15 +612,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
setError(errorMessage); setError(errorMessage);
return null; return null;
} }
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]); }, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
const cancelOrder = useCallback(async (poId: string, reason: string): Promise<PurchaseOrder | null> => { const cancelOrder = useCallback(async (poId: string, reason: string): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setError(null); setError(null);
const updatedOrder = await suppliersService.cancelPurchaseOrder(user.tenant_id, user.user_id, poId, reason); const updatedOrder = await suppliersService.cancelPurchaseOrder(user.tenant_id, user.id, poId, reason);
if (purchaseOrder?.id === poId) { if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder); setPurchaseOrder(updatedOrder);
@@ -618,7 +635,7 @@ export function usePurchaseOrders(): UsePurchaseOrders {
setError(errorMessage); setError(errorMessage);
return null; return null;
} }
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]); }, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
const clearError = useCallback(() => { const clearError = useCallback(() => {
setError(null); setError(null);
@@ -804,12 +821,12 @@ export function useDeliveries(): UseDeliveries {
}, [user?.tenant_id]); }, [user?.tenant_id]);
const updateDeliveryStatus = useCallback(async (deliveryId: string, status: string, notes?: string): Promise<Delivery | null> => { const updateDeliveryStatus = useCallback(async (deliveryId: string, status: string, notes?: string): Promise<Delivery | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setError(null); setError(null);
const updatedDelivery = await suppliersService.updateDeliveryStatus(user.tenant_id, user.user_id, deliveryId, status, notes); const updatedDelivery = await suppliersService.updateDeliveryStatus(user.tenant_id, user.id, deliveryId, status, notes);
if (delivery?.id === deliveryId) { if (delivery?.id === deliveryId) {
setDelivery(updatedDelivery); setDelivery(updatedDelivery);
@@ -824,15 +841,15 @@ export function useDeliveries(): UseDeliveries {
setError(errorMessage); setError(errorMessage);
return null; return null;
} }
}, [user?.tenant_id, user?.user_id, delivery?.id, loadDeliveries, currentParams]); }, [user?.tenant_id, user?.id, delivery?.id, loadDeliveries, currentParams]);
const receiveDelivery = useCallback(async (deliveryId: string, receiptData: any): Promise<Delivery | null> => { const receiveDelivery = useCallback(async (deliveryId: string, receiptData: any): Promise<Delivery | null> => {
if (!user?.tenant_id || !user?.user_id) return null; if (!user?.tenant_id || !user?.id) return null;
try { try {
setError(null); setError(null);
const updatedDelivery = await suppliersService.receiveDelivery(user.tenant_id, user.user_id, deliveryId, receiptData); const updatedDelivery = await suppliersService.receiveDelivery(user.tenant_id, user.id, deliveryId, receiptData);
if (delivery?.id === deliveryId) { if (delivery?.id === deliveryId) {
setDelivery(updatedDelivery); setDelivery(updatedDelivery);
@@ -847,7 +864,7 @@ export function useDeliveries(): UseDeliveries {
setError(errorMessage); setError(errorMessage);
return null; return null;
} }
}, [user?.tenant_id, user?.user_id, delivery?.id, loadDeliveries, currentParams]); }, [user?.tenant_id, user?.id, delivery?.id, loadDeliveries, currentParams]);
const clearError = useCallback(() => { const clearError = useCallback(() => {
setError(null); setError(null);

View File

@@ -43,6 +43,7 @@ export interface InventoryItem {
supplier?: string; supplier?: string;
notes?: string; notes?: string;
barcode?: string; barcode?: string;
sku?: string;
cost_per_unit?: number; cost_per_unit?: number;
is_active: boolean; is_active: boolean;
created_at: string; created_at: string;

View File

@@ -211,7 +211,20 @@ export class OnboardingService {
suggestions: suggestions.map(s => ({ suggestions: suggestions.map(s => ({
suggestion_id: s.suggestion_id, suggestion_id: s.suggestion_id,
approved: s.user_approved ?? true, approved: s.user_approved ?? true,
modifications: s.user_modifications || {} modifications: s.user_modifications || {},
// Include full suggestion data for backend processing
original_name: s.original_name,
suggested_name: s.suggested_name,
product_type: s.product_type,
category: s.category,
unit_of_measure: s.unit_of_measure,
confidence_score: s.confidence_score,
estimated_shelf_life_days: s.estimated_shelf_life_days,
requires_refrigeration: s.requires_refrigeration,
requires_freezing: s.requires_freezing,
is_seasonal: s.is_seasonal,
suggested_supplier: s.suggested_supplier,
notes: s.notes
})) }))
}); });
} }

View File

@@ -367,14 +367,14 @@ export class RecipesService {
headers: { 'X-Tenant-ID': tenantId }, headers: { 'X-Tenant-ID': tenantId },
params params
}); });
return response.data; return response;
} }
async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> { async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> {
const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, { const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, {
headers: { 'X-Tenant-ID': tenantId } headers: { 'X-Tenant-ID': tenantId }
}); });
return response.data; return response;
} }
async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> { async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> {
@@ -384,7 +384,7 @@ export class RecipesService {
'X-User-ID': userId 'X-User-ID': userId
} }
}); });
return response.data; return response;
} }
async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> { async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> {
@@ -394,7 +394,7 @@ export class RecipesService {
'X-User-ID': userId 'X-User-ID': userId
} }
}); });
return response.data; return response;
} }
async deleteRecipe(tenantId: string, recipeId: string): Promise<void> { async deleteRecipe(tenantId: string, recipeId: string): Promise<void> {
@@ -413,7 +413,7 @@ export class RecipesService {
} }
} }
); );
return response.data; return response;
} }
async activateRecipe(tenantId: string, userId: string, recipeId: string): Promise<Recipe> { async activateRecipe(tenantId: string, userId: string, recipeId: string): Promise<Recipe> {
@@ -423,7 +423,7 @@ export class RecipesService {
'X-User-ID': userId 'X-User-ID': userId
} }
}); });
return response.data; return response;
} }
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> { async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> {
@@ -431,21 +431,21 @@ export class RecipesService {
headers: { 'X-Tenant-ID': tenantId }, headers: { 'X-Tenant-ID': tenantId },
params: { batch_multiplier: batchMultiplier } params: { batch_multiplier: batchMultiplier }
}); });
return response.data; return response;
} }
async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> { async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> {
const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, { const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, {
headers: { 'X-Tenant-ID': tenantId } headers: { 'X-Tenant-ID': tenantId }
}); });
return response.data; return response;
} }
async getRecipeCategories(tenantId: string): Promise<string[]> { async getRecipeCategories(tenantId: string): Promise<string[]> {
const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, { const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, {
headers: { 'X-Tenant-ID': tenantId } headers: { 'X-Tenant-ID': tenantId }
}); });
return response.data.categories; return response.categories;
} }
// Production Management // Production Management
@@ -454,14 +454,14 @@ export class RecipesService {
headers: { 'X-Tenant-ID': tenantId }, headers: { 'X-Tenant-ID': tenantId },
params params
}); });
return response.data; return response;
} }
async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> { async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> {
const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, { const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, {
headers: { 'X-Tenant-ID': tenantId } headers: { 'X-Tenant-ID': tenantId }
}); });
return response.data; return response;
} }
async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> { async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> {
@@ -471,7 +471,7 @@ export class RecipesService {
'X-User-ID': userId 'X-User-ID': userId
} }
}); });
return response.data; return response;
} }
async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> { async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> {
@@ -481,7 +481,7 @@ export class RecipesService {
'X-User-ID': userId 'X-User-ID': userId
} }
}); });
return response.data; return response;
} }
async deleteProductionBatch(tenantId: string, batchId: string): Promise<void> { async deleteProductionBatch(tenantId: string, batchId: string): Promise<void> {
@@ -494,7 +494,7 @@ export class RecipesService {
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches/active/list`, { const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches/active/list`, {
headers: { 'X-Tenant-ID': tenantId } headers: { 'X-Tenant-ID': tenantId }
}); });
return response.data; return response;
} }
async startProductionBatch(tenantId: string, userId: string, batchId: string, data: { async startProductionBatch(tenantId: string, userId: string, batchId: string, data: {
@@ -519,7 +519,7 @@ export class RecipesService {
'X-User-ID': userId 'X-User-ID': userId
} }
}); });
return response.data; return response;
} }
async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: { async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: {
@@ -538,7 +538,7 @@ export class RecipesService {
'X-User-ID': userId 'X-User-ID': userId
} }
}); });
return response.data; return response;
} }
async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> { async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> {
@@ -546,6 +546,6 @@ export class RecipesService {
headers: { 'X-Tenant-ID': tenantId }, headers: { 'X-Tenant-ID': tenantId },
params: { start_date: startDate, end_date: endDate } params: { start_date: startDate, end_date: endDate }
}); });
return response.data; return response;
} }
} }

View File

@@ -361,7 +361,7 @@ export class SuppliersService {
`${this.baseUrl}?${searchParams.toString()}`, `${this.baseUrl}?${searchParams.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getSupplier(tenantId: string, supplierId: string): Promise<Supplier> { async getSupplier(tenantId: string, supplierId: string): Promise<Supplier> {
@@ -369,7 +369,7 @@ export class SuppliersService {
`${this.baseUrl}/${supplierId}`, `${this.baseUrl}/${supplierId}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async createSupplier(tenantId: string, userId: string, data: CreateSupplierRequest): Promise<Supplier> { async createSupplier(tenantId: string, userId: string, data: CreateSupplierRequest): Promise<Supplier> {
@@ -378,7 +378,7 @@ export class SuppliersService {
data, data,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async updateSupplier(tenantId: string, userId: string, supplierId: string, data: UpdateSupplierRequest): Promise<Supplier> { async updateSupplier(tenantId: string, userId: string, supplierId: string, data: UpdateSupplierRequest): Promise<Supplier> {
@@ -387,7 +387,7 @@ export class SuppliersService {
data, data,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async deleteSupplier(tenantId: string, supplierId: string): Promise<void> { async deleteSupplier(tenantId: string, supplierId: string): Promise<void> {
@@ -403,7 +403,7 @@ export class SuppliersService {
{ action, notes }, { action, notes },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
// Supplier Analytics & Lists // Supplier Analytics & Lists
@@ -412,7 +412,7 @@ export class SuppliersService {
`${this.baseUrl}/statistics`, `${this.baseUrl}/statistics`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getActiveSuppliers(tenantId: string): Promise<SupplierSummary[]> { async getActiveSuppliers(tenantId: string): Promise<SupplierSummary[]> {
@@ -420,7 +420,7 @@ export class SuppliersService {
`${this.baseUrl}/active`, `${this.baseUrl}/active`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getTopSuppliers(tenantId: string, limit: number = 10): Promise<SupplierSummary[]> { async getTopSuppliers(tenantId: string, limit: number = 10): Promise<SupplierSummary[]> {
@@ -428,7 +428,7 @@ export class SuppliersService {
`${this.baseUrl}/top?limit=${limit}`, `${this.baseUrl}/top?limit=${limit}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getSuppliersByType(tenantId: string, supplierType: string): Promise<SupplierSummary[]> { async getSuppliersByType(tenantId: string, supplierType: string): Promise<SupplierSummary[]> {
@@ -436,7 +436,7 @@ export class SuppliersService {
`${this.baseUrl}/types/${supplierType}`, `${this.baseUrl}/types/${supplierType}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getSuppliersNeedingReview(tenantId: string, daysSinceLastOrder: number = 30): Promise<SupplierSummary[]> { async getSuppliersNeedingReview(tenantId: string, daysSinceLastOrder: number = 30): Promise<SupplierSummary[]> {
@@ -444,7 +444,7 @@ export class SuppliersService {
`${this.baseUrl}/pending-review?days_since_last_order=${daysSinceLastOrder}`, `${this.baseUrl}/pending-review?days_since_last_order=${daysSinceLastOrder}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
// Purchase Orders // Purchase Orders
@@ -463,7 +463,7 @@ export class SuppliersService {
`/api/v1/purchase-orders?${searchParams.toString()}`, `/api/v1/purchase-orders?${searchParams.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getPurchaseOrder(tenantId: string, poId: string): Promise<PurchaseOrder> { async getPurchaseOrder(tenantId: string, poId: string): Promise<PurchaseOrder> {
@@ -471,7 +471,7 @@ export class SuppliersService {
`/api/v1/purchase-orders/${poId}`, `/api/v1/purchase-orders/${poId}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async createPurchaseOrder(tenantId: string, userId: string, data: CreatePurchaseOrderRequest): Promise<PurchaseOrder> { async createPurchaseOrder(tenantId: string, userId: string, data: CreatePurchaseOrderRequest): Promise<PurchaseOrder> {
@@ -480,7 +480,7 @@ export class SuppliersService {
data, data,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async updatePurchaseOrderStatus(tenantId: string, userId: string, poId: string, status: string, notes?: string): Promise<PurchaseOrder> { async updatePurchaseOrderStatus(tenantId: string, userId: string, poId: string, status: string, notes?: string): Promise<PurchaseOrder> {
@@ -489,7 +489,7 @@ export class SuppliersService {
{ status, notes }, { status, notes },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async approvePurchaseOrder(tenantId: string, userId: string, poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder> { async approvePurchaseOrder(tenantId: string, userId: string, poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder> {
@@ -498,7 +498,7 @@ export class SuppliersService {
{ action, notes }, { action, notes },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async sendToSupplier(tenantId: string, userId: string, poId: string, sendEmail: boolean = true): Promise<PurchaseOrder> { async sendToSupplier(tenantId: string, userId: string, poId: string, sendEmail: boolean = true): Promise<PurchaseOrder> {
@@ -507,7 +507,7 @@ export class SuppliersService {
{}, {},
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async cancelPurchaseOrder(tenantId: string, userId: string, poId: string, reason: string): Promise<PurchaseOrder> { async cancelPurchaseOrder(tenantId: string, userId: string, poId: string, reason: string): Promise<PurchaseOrder> {
@@ -516,7 +516,7 @@ export class SuppliersService {
{}, {},
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async getPurchaseOrderStatistics(tenantId: string): Promise<PurchaseOrderStatistics> { async getPurchaseOrderStatistics(tenantId: string): Promise<PurchaseOrderStatistics> {
@@ -524,7 +524,7 @@ export class SuppliersService {
'/api/v1/purchase-orders/statistics', '/api/v1/purchase-orders/statistics',
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getOrdersRequiringApproval(tenantId: string): Promise<PurchaseOrder[]> { async getOrdersRequiringApproval(tenantId: string): Promise<PurchaseOrder[]> {
@@ -532,7 +532,7 @@ export class SuppliersService {
'/api/v1/purchase-orders/pending-approval', '/api/v1/purchase-orders/pending-approval',
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getOverdueOrders(tenantId: string): Promise<PurchaseOrder[]> { async getOverdueOrders(tenantId: string): Promise<PurchaseOrder[]> {
@@ -540,7 +540,7 @@ export class SuppliersService {
'/api/v1/purchase-orders/overdue', '/api/v1/purchase-orders/overdue',
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
// Deliveries // Deliveries
@@ -558,7 +558,7 @@ export class SuppliersService {
`/api/v1/deliveries?${searchParams.toString()}`, `/api/v1/deliveries?${searchParams.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getDelivery(tenantId: string, deliveryId: string): Promise<Delivery> { async getDelivery(tenantId: string, deliveryId: string): Promise<Delivery> {
@@ -566,7 +566,7 @@ export class SuppliersService {
`/api/v1/deliveries/${deliveryId}`, `/api/v1/deliveries/${deliveryId}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getTodaysDeliveries(tenantId: string): Promise<Delivery[]> { async getTodaysDeliveries(tenantId: string): Promise<Delivery[]> {
@@ -574,7 +574,7 @@ export class SuppliersService {
'/api/v1/deliveries/today', '/api/v1/deliveries/today',
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async getOverdueDeliveries(tenantId: string): Promise<Delivery[]> { async getOverdueDeliveries(tenantId: string): Promise<Delivery[]> {
@@ -582,7 +582,7 @@ export class SuppliersService {
'/api/v1/deliveries/overdue', '/api/v1/deliveries/overdue',
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
async updateDeliveryStatus(tenantId: string, userId: string, deliveryId: string, status: string, notes?: string): Promise<Delivery> { async updateDeliveryStatus(tenantId: string, userId: string, deliveryId: string, status: string, notes?: string): Promise<Delivery> {
@@ -591,7 +591,7 @@ export class SuppliersService {
{ status, notes, update_timestamps: true }, { status, notes, update_timestamps: true },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async receiveDelivery(tenantId: string, userId: string, deliveryId: string, receiptData: { async receiveDelivery(tenantId: string, userId: string, deliveryId: string, receiptData: {
@@ -605,7 +605,7 @@ export class SuppliersService {
receiptData, receiptData,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } } { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
); );
return response.data; return response;
} }
async getDeliveryPerformanceStats(tenantId: string, daysBack: number = 30, supplierId?: string): Promise<DeliveryPerformanceStats> { async getDeliveryPerformanceStats(tenantId: string, daysBack: number = 30, supplierId?: string): Promise<DeliveryPerformanceStats> {
@@ -617,6 +617,6 @@ export class SuppliersService {
`/api/v1/deliveries/performance-stats?${params.toString()}`, `/api/v1/deliveries/performance-stats?${params.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } } { headers: { 'X-Tenant-ID': tenantId } }
); );
return response.data; return response;
} }
} }

View File

@@ -39,4 +39,16 @@ export interface BaseQueryParams {
search?: string; search?: string;
sort?: string; sort?: string;
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
}
export interface CreateResponse<T = any> {
data: T;
message?: string;
status: string;
}
export interface UpdateResponse<T = any> {
data: T;
message?: string;
status: string;
} }

View File

@@ -18,6 +18,15 @@ export interface SalesData {
source: string; source: string;
created_at: string; created_at: string;
external_factors?: ExternalFactors; external_factors?: ExternalFactors;
// Additional properties used by components
sales_channel?: string;
is_validated?: boolean;
cost_of_goods?: number;
revenue?: number;
quantity_sold?: number;
inventory_product_id?: string;
discount_applied?: number;
weather_condition?: string;
} }
export interface SalesValidationResult { export interface SalesValidationResult {
@@ -53,6 +62,10 @@ export interface SalesDataQuery extends BaseQueryParams {
max_quantity?: number; max_quantity?: number;
min_revenue?: number; min_revenue?: number;
max_revenue?: number; max_revenue?: number;
search_term?: string;
sales_channel?: string;
inventory_product_id?: string;
is_validated?: boolean;
} }
export interface SalesDataImport { export interface SalesDataImport {

View File

@@ -14,6 +14,8 @@ export interface TenantInfo {
settings?: TenantSettings; settings?: TenantSettings;
subscription?: TenantSubscription; subscription?: TenantSubscription;
location?: TenantLocation; location?: TenantLocation;
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
} }
export interface TenantSettings { export interface TenantSettings {
@@ -62,7 +64,8 @@ export interface TenantSubscription {
export interface TenantCreate { export interface TenantCreate {
name: string; name: string;
address?: string; address?: string;
business_type?: 'individual' | 'central_workshop'; business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
postal_code: string; postal_code: string;
phone: string; phone: string;
description?: string; description?: string;

View File

@@ -120,8 +120,8 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
const config = { const config = {
url: actualTenantId url: actualTenantId
? `ws://localhost:8002/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live` ? `ws://localhost:8000/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live`
: `ws://localhost:8002/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`, : `ws://localhost:8000/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
reconnect: true, reconnect: true,
reconnectInterval: 3000, reconnectInterval: 3000,
maxReconnectAttempts: 10 maxReconnectAttempts: 10

View File

@@ -16,7 +16,9 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
ArrowRight, ArrowRight,
Lightbulb Lightbulb,
Building2,
Truck
} from 'lucide-react'; } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -236,6 +238,43 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => { const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => {
const modelConfig = { const modelConfig = {
individual_bakery: {
icon: Factory,
title: 'Panadería Individual',
description: 'Producción completa desde ingredientes básicos (harina, levadura, etc.)',
color: 'blue',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
textColor: 'text-blue-900'
},
central_baker_satellite: {
icon: Truck,
title: 'Punto de Venta - Obrador Central',
description: 'Recibe productos semi-elaborados y los finaliza (horneado, decoración)',
color: 'amber',
bgColor: 'bg-amber-50',
borderColor: 'border-amber-200',
textColor: 'text-amber-900'
},
retail_bakery: {
icon: Store,
title: 'Panadería de Distribución',
description: 'Vende productos terminados de proveedores externos',
color: 'green',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-900'
},
hybrid_bakery: {
icon: Settings2,
title: 'Modelo Mixto',
description: 'Combina producción propia con productos de proveedores',
color: 'purple',
bgColor: 'bg-purple-50',
borderColor: 'border-purple-200',
textColor: 'text-purple-900'
},
// Legacy fallbacks
production: { production: {
icon: Factory, icon: Factory,
title: 'Panadería de Producción', title: 'Panadería de Producción',
@@ -265,7 +304,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
} }
}; };
const config = modelConfig[analysis.model]; // Provide fallback if analysis.model is not in modelConfig
const config = modelConfig[analysis.model as keyof typeof modelConfig] || modelConfig.hybrid_bakery;
const IconComponent = config.icon; const IconComponent = config.icon;
return ( return (

View File

@@ -43,8 +43,8 @@ const SalesAnalyticsDashboard: React.FC = () => {
} = useSales(); } = useSales();
const { const {
ingredients: products, items: products,
loadIngredients: loadProducts, loadItems: loadProducts,
isLoading: inventoryLoading isLoading: inventoryLoading
} = useInventory(); } = useInventory();
@@ -159,11 +159,14 @@ const SalesAnalyticsDashboard: React.FC = () => {
// Top products // Top products
const topProducts = Object.entries(productPerformance) const topProducts = Object.entries(productPerformance)
.map(([productId, data]) => ({ .map(([productId, data]) => {
productId, const perf = data as { revenue: number; units: number; orders: number };
...data as any, return {
avgPrice: data.revenue / data.units productId,
})) ...perf,
avgPrice: perf.revenue / perf.units
};
})
.sort((a, b) => b.revenue - a.revenue) .sort((a, b) => b.revenue - a.revenue)
.slice(0, 5); .slice(0, 5);

View File

@@ -160,7 +160,7 @@ const SalesDashboardWidget: React.FC<SalesDashboardWidgetProps> = ({
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">Ventas de Hoy</h3> <h3 className="font-semibold text-gray-900">Ventas de Hoy</h3>
{onViewAll && ( {onViewAll && (
<Button variant="ghost" size="sm" onClick={onViewAll}> <Button variant="outline" size="sm" onClick={onViewAll}>
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Button> </Button>
)} )}

View File

@@ -10,7 +10,7 @@ import {
Package, Package,
ShoppingCart, ShoppingCart,
MapPin, MapPin,
Grid3X3, Grid,
List, List,
AlertCircle, AlertCircle,
TrendingUp, TrendingUp,
@@ -51,8 +51,8 @@ const SalesManagementPage: React.FC = () => {
} = useSales(); } = useSales();
const { const {
ingredients: products, items: products,
loadIngredients: loadProducts, loadItems: loadProducts,
isLoading: inventoryLoading isLoading: inventoryLoading
} = useInventory(); } = useInventory();
@@ -89,7 +89,9 @@ const SalesManagementPage: React.FC = () => {
const loadSalesData = async () => { const loadSalesData = async () => {
if (!user?.tenant_id) return; if (!user?.tenant_id) return;
const query: SalesDataQuery = {}; const query: SalesDataQuery = {
tenant_id: user.tenant_id
};
if (filters.search) { if (filters.search) {
query.search_term = filters.search; query.search_term = filters.search;
@@ -155,7 +157,9 @@ const SalesManagementPage: React.FC = () => {
const handleExport = async () => { const handleExport = async () => {
if (!user?.tenant_id) return; if (!user?.tenant_id) return;
const query: SalesDataQuery = {}; const query: SalesDataQuery = {
tenant_id: user.tenant_id
};
if (filters.date_from) query.start_date = filters.date_from; if (filters.date_from) query.start_date = filters.date_from;
if (filters.date_to) query.end_date = filters.date_to; if (filters.date_to) query.end_date = filters.date_to;
if (filters.channel) query.sales_channel = filters.channel; if (filters.channel) query.sales_channel = filters.channel;
@@ -386,7 +390,7 @@ const SalesManagementPage: React.FC = () => {
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
> >
<Grid3X3 className="w-4 h-4" /> <Grid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}

View File

@@ -11,7 +11,7 @@ import {
Clock, Clock,
Star, Star,
ArrowRight, ArrowRight,
LightBulb, Lightbulb,
Calendar, Calendar,
Package Package
} from 'lucide-react'; } from 'lucide-react';
@@ -53,10 +53,10 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
} = useSales(); } = useSales();
const { const {
predictions, forecasts,
loadPredictions, getForecasts,
performance, quickForecasts,
loadPerformance, getQuickForecasts,
isLoading: forecastLoading isLoading: forecastLoading
} = useForecast(); } = useForecast();
@@ -87,8 +87,8 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
end_date: endDate, end_date: endDate,
limit: 1000 limit: 1000
}), }),
loadPredictions(), getForecasts(user.tenant_id),
loadPerformance() getQuickForecasts(user.tenant_id)
]); ]);
setSalesAnalytics(analytics); setSalesAnalytics(analytics);
@@ -200,9 +200,9 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
} }
// Forecasting insights // Forecasting insights
if (predictions.length > 0) { if (forecasts.length > 0) {
const todayPrediction = predictions.find(p => { const todayPrediction = forecasts.find(p => {
const predDate = new Date(p.date).toDateString(); const predDate = new Date(p.forecast_date).toDateString();
const today = new Date().toDateString(); const today = new Date().toDateString();
return predDate === today; return predDate === today;
}); });
@@ -213,8 +213,8 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
type: 'forecast', type: 'forecast',
title: 'Predicción para hoy', title: 'Predicción para hoy',
description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${ description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${
todayPrediction.confidence === 'high' ? 'alta' : (todayPrediction.confidence_level || 0) > 0.8 ? 'alta' :
todayPrediction.confidence === 'medium' ? 'media' : 'baja' (todayPrediction.confidence_level || 0) > 0.6 ? 'media' : 'baja'
} confianza.`, } confianza.`,
value: `${todayPrediction.predicted_demand} unidades`, value: `${todayPrediction.predicted_demand} unidades`,
priority: 'high', priority: 'high',
@@ -227,8 +227,9 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
} }
// Performance vs forecast insight // Performance vs forecast insight
if (performance) { if (quickForecasts.length > 0) {
const accuracy = performance.accuracy || 0; const latestForecast = quickForecasts[0];
const accuracy = latestForecast.confidence_score || 0;
if (accuracy > 85) { if (accuracy > 85) {
insights.push({ insights.push({
id: 'forecast_accuracy', id: 'forecast_accuracy',
@@ -288,7 +289,7 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
// Sort by priority // Sort by priority
const priorityOrder = { high: 3, medium: 2, low: 1 }; const priorityOrder = { high: 3, medium: 2, low: 1 };
return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]); return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
}, [salesAnalytics, salesData, predictions, performance, onActionClick]); }, [salesAnalytics, salesData, forecasts, quickForecasts, onActionClick]);
// Get insight icon // Get insight icon
const getInsightIcon = (type: PerformanceInsight['type']) => { const getInsightIcon = (type: PerformanceInsight['type']) => {
@@ -301,7 +302,7 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
return Brain; return Brain;
case 'info': case 'info':
default: default:
return LightBulb; return Lightbulb;
} }
}; };

View File

@@ -11,7 +11,7 @@ import {
CheckCircle, CheckCircle,
Calendar, Calendar,
TrendingUp, TrendingUp,
Grid3X3, Grid,
List, List,
Download, Download,
MapPin, MapPin,
@@ -486,7 +486,7 @@ const DeliveryTrackingPage: React.FC = () => {
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
> >
<Grid3X3 className="w-4 h-4" /> <Grid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}

View File

@@ -100,7 +100,7 @@ const PurchaseOrderForm: React.FC<PurchaseOrderFormProps> = ({
const [activeTab, setActiveTab] = useState<'basic' | 'delivery' | 'items' | 'financial'>('basic'); const [activeTab, setActiveTab] = useState<'basic' | 'delivery' | 'items' | 'financial'>('basic');
const { activeSuppliers, loadActiveSuppliers } = useSuppliers(); const { activeSuppliers, loadActiveSuppliers } = useSuppliers();
const { ingredients, loadInventoryItems } = useInventory(); const { items: ingredients, loadItems: loadInventoryItems } = useInventory();
// Initialize form data when order changes // Initialize form data when order changes
useEffect(() => { useEffect(() => {

View File

@@ -12,7 +12,7 @@ import {
AlertCircle, AlertCircle,
Package, Package,
DollarSign, DollarSign,
Grid3X3, Grid,
List List
} from 'lucide-react'; } from 'lucide-react';
@@ -446,7 +446,7 @@ const PurchaseOrderManagementPage: React.FC = () => {
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
> >
<Grid3X3 className="w-4 h-4" /> <Grid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
@@ -599,7 +599,7 @@ const PurchaseOrderManagementPage: React.FC = () => {
isOpen={showPurchaseOrderForm} isOpen={showPurchaseOrderForm}
isCreating={isCreating} isCreating={isCreating}
onSubmit={selectedOrder ? onSubmit={selectedOrder ?
(data) => { async (data) => {
// Handle update logic here if needed // Handle update logic here if needed
setShowPurchaseOrderForm(false); setShowPurchaseOrderForm(false);
setSelectedOrder(null); setSelectedOrder(null);

View File

@@ -43,6 +43,8 @@ interface SupplierCostData extends SupplierSummary {
market_share_percentage: number; market_share_percentage: number;
cost_trend: 'increasing' | 'decreasing' | 'stable'; cost_trend: 'increasing' | 'decreasing' | 'stable';
cost_efficiency_score: number; cost_efficiency_score: number;
total_amount: number;
quality_rating: number;
} }
const SupplierCostAnalysis: React.FC = () => { const SupplierCostAnalysis: React.FC = () => {

View File

@@ -12,7 +12,7 @@ import {
AlertCircle, AlertCircle,
Package, Package,
DollarSign, DollarSign,
Grid3X3, Grid,
List List
} from 'lucide-react'; } from 'lucide-react';
@@ -422,7 +422,7 @@ const SupplierManagementPage: React.FC = () => {
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
> >
<Grid3X3 className="w-4 h-4" /> <Grid className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}

View File

@@ -42,6 +42,10 @@ interface SupplierPerformance extends SupplierSummary {
cost_efficiency: number; cost_efficiency: number;
response_time: number; response_time: number;
quality_consistency: number; quality_consistency: number;
total_orders: number;
total_amount: number;
quality_rating: number;
delivery_rating: number;
} }
const SupplierPerformanceReport: React.FC = () => { const SupplierPerformanceReport: React.FC = () => {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader, TrendingUp } from 'lucide-react'; import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader, TrendingUp } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress'; import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
@@ -25,7 +25,7 @@ interface OnboardingPageProps {
interface BakeryData { interface BakeryData {
name: string; name: string;
address: string; address: string;
businessType: 'individual' | 'central_workshop'; businessType?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant'; // Will be auto-detected from sales data
coordinates?: { lat: number; lng: number }; coordinates?: { lat: number; lng: number };
products: string[]; products: string[];
hasHistoricalData: boolean; hasHistoricalData: boolean;
@@ -51,7 +51,6 @@ const MADRID_PRODUCTS = [
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => { const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [useSmartImport, setUseSmartImport] = useState(true); // New state for smart import
const manualNavigation = useRef(false); const manualNavigation = useRef(false);
// Enhanced onboarding with progress tracking // Enhanced onboarding with progress tracking
@@ -64,7 +63,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
const [bakeryData, setBakeryData] = useState<BakeryData>({ const [bakeryData, setBakeryData] = useState<BakeryData>({
name: '', name: '',
address: '', address: '',
businessType: 'individual', // businessType will be auto-detected during smart data import
products: MADRID_PRODUCTS, // Automatically assign all products products: MADRID_PRODUCTS, // Automatically assign all products
hasHistoricalData: false hasHistoricalData: false
}); });
@@ -95,14 +94,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
estimatedTimeRemaining: 0 estimatedTimeRemaining: 0
}); });
// Sales data validation state
const [validationStatus, setValidationStatus] = useState<{
status: 'idle' | 'validating' | 'valid' | 'invalid';
message?: string;
records?: number;
}>({
status: 'idle'
});
// Initialize current step from onboarding progress (only when not manually navigating) // Initialize current step from onboarding progress (only when not manually navigating)
useEffect(() => { useEffect(() => {
@@ -292,8 +283,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
try { try {
if (currentStep === 1) { if (currentStep === 1) {
await createBakeryAndTenant(); await createBakeryAndTenant();
} else if (currentStep === 2) { // Smart import step handles its own processing
await uploadAndValidateSalesData();
} }
} catch (error) { } catch (error) {
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.'); toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
@@ -317,7 +307,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
const tenantData: TenantCreate = { const tenantData: TenantCreate = {
name: bakeryData.name, name: bakeryData.name,
address: bakeryData.address, address: bakeryData.address,
business_type: "individual", business_type: 'bakery', // Default value - will be automatically updated after AI analyzes sales data
postal_code: "28010", postal_code: "28010",
phone: "+34655334455", phone: "+34655334455",
coordinates: bakeryData.coordinates, coordinates: bakeryData.coordinates,
@@ -337,7 +327,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
await completeStep('bakery_registered', { await completeStep('bakery_registered', {
bakery_name: bakeryData.name, bakery_name: bakeryData.name,
bakery_address: bakeryData.address, bakery_address: bakeryData.address,
business_type: bakeryData.businessType, business_type: 'bakery', // Default - will be auto-detected from sales data
auto_detect_business_type: true, // Flag indicating business type will be auto-detected
tenant_id: currentTenantId, // Use the correct tenant ID tenant_id: currentTenantId, // Use the correct tenant ID
user_id: user?.id user_id: user?.id
}); });
@@ -354,81 +345,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
} }
}; };
// Validate sales data without importing
const validateSalesFile = async () => {
if (!tenantId || !bakeryData.csvFile) {
toast.error('Falta información del tenant o archivo');
return;
}
setValidationStatus({ status: 'validating' });
try {
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
if (validationResult.is_valid) {
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
};
// Upload and validate sales data for step 2
const uploadAndValidateSalesData = async () => {
if (!tenantId || !bakeryData.csvFile) {
throw new Error('Falta información del tenant o archivo');
}
try {
// If not already validated, validate first
if (validationStatus.status !== 'valid') {
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
if (!validationResult.is_valid) {
toast.error(`Error en validación: ${validationResult.message}`);
return;
}
}
// If validation passes, upload the data
const uploadResult = await uploadSalesHistory(tenantId, bakeryData.csvFile);
// Mark step as completed
await completeStep('sales_data_uploaded', {
file_name: bakeryData.csvFile.name,
file_size: bakeryData.csvFile.size,
records_imported: uploadResult.records_imported || 0,
validation_status: 'success',
tenant_id: tenantId,
user_id: user?.id
});
toast.success(`Datos importados correctamente (${uploadResult.records_imported} registros)`);
// Automatically start training after successful upload
await startTraining();
} catch (error: any) {
// Error uploading sales data
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
throw new Error(message);
}
};
// Helper function to mark current step as completed // Helper function to mark current step as completed
const markStepCompleted = async () => { const markStepCompleted = async () => {
@@ -450,7 +367,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
if (currentStep === 1) { if (currentStep === 1) {
stepData.bakery_name = bakeryData.name; stepData.bakery_name = bakeryData.name;
stepData.bakery_address = bakeryData.address; stepData.bakery_address = bakeryData.address;
stepData.business_type = bakeryData.businessType; stepData.business_type = 'bakery'; // Default - will be auto-detected
stepData.auto_detect_business_type = true;
stepData.products_count = bakeryData.products.length; stepData.products_count = bakeryData.products.length;
} else if (currentStep === 2) { } else if (currentStep === 2) {
stepData.file_name = bakeryData.csvFile?.name; stepData.file_name = bakeryData.csvFile?.name;
@@ -479,39 +397,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
} }
return true; return true;
case 2: case 2:
// Skip validation if using smart import (it handles its own validation) // Smart import handles its own validation
if (useSmartImport) {
return true;
}
if (!bakeryData.csvFile) {
toast.error('Por favor, selecciona un archivo con tus datos históricos');
return false;
}
// Validate file format
const fileName = bakeryData.csvFile.name.toLowerCase();
const supportedFormats = ['.csv', '.xlsx', '.xls', '.json'];
const isValidFormat = supportedFormats.some(format => fileName.endsWith(format));
if (!isValidFormat) {
toast.error('Formato de archivo no soportado. Usa CSV, Excel (.xlsx, .xls) o JSON');
return false;
}
// Validate file size (10MB limit as per backend)
const maxSize = 10 * 1024 * 1024;
if (bakeryData.csvFile.size > maxSize) {
toast.error('El archivo es demasiado grande. Máximo 10MB');
return false;
}
// Check if validation was successful
if (validationStatus.status !== 'valid') {
toast.error('Por favor, valida el archivo antes de continuar');
return false;
}
return true; return true;
default: default:
return true; return true;
@@ -672,37 +558,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</div> </div>
</div> </div>
<div> <div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-700 mb-3"> <div className="flex items-start">
Tipo de negocio <div className="flex-shrink-0">
</label> <Brain className="w-5 h-5 text-blue-500 mt-0.5" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> </div>
<button <div className="ml-3">
type="button" <h4 className="text-sm font-medium text-blue-900">
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'individual' }))} 🤖 Detección automática del tipo de negocio
className={`p-4 border rounded-xl text-left transition-all ${ </h4>
bakeryData.businessType === 'individual' <p className="text-sm text-blue-700 mt-1">
? 'border-primary-500 bg-primary-50 text-primary-700' Nuestro sistema de IA analizará automáticamente tus datos de ventas para identificar
: 'border-gray-300 hover:border-gray-400' si eres una panadería, cafetería, pastelería o restaurante. No necesitas seleccionar nada manualmente.
}`} </p>
> </div>
<Store className="h-5 w-5 mb-2" />
<div className="font-medium">Panadería Individual</div>
<div className="text-sm text-gray-500">Un solo punto de venta</div>
</button>
<button
type="button"
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'central_workshop' }))}
className={`p-4 border rounded-xl text-left transition-all ${
bakeryData.businessType === 'central_workshop'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<Factory className="h-5 w-5 mb-2" />
<div className="font-medium">Obrador Central</div>
<div className="text-sm text-gray-500">Múltiples puntos de venta</div>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -724,360 +593,30 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
); );
} }
// Use Smart Import by default, with option to switch to traditional // Smart Import only
if (useSmartImport) {
return (
<div className="space-y-6">
{/* Header with import mode toggle */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-semibold text-gray-900">
Importación Inteligente de Datos 🧠
</h3>
<p className="text-gray-600 mt-1">
Nuestra IA creará automáticamente tu inventario desde tus datos históricos
</p>
</div>
<button
onClick={() => setUseSmartImport(false)}
className="flex items-center space-x-2 px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Upload className="w-4 h-4" />
<span>Importación tradicional</span>
</button>
</div>
{/* Smart Import Component */}
<SmartHistoricalDataImport
tenantId={tenantId}
onComplete={(result) => {
// Mark sales data as uploaded and proceed to training
completeStep('sales_data_uploaded', {
smart_import: true,
records_imported: result.successful_imports,
import_job_id: result.import_job_id,
tenant_id: tenantId,
user_id: user?.id
}).then(() => {
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
startTraining();
}).catch(() => {
// Continue even if step completion fails
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
startTraining();
});
}}
onBack={() => setUseSmartImport(false)}
/>
</div>
);
}
// Traditional import fallback
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header with import mode toggle */} {/* Smart Import Component */}
<div className="flex items-center justify-between mb-6"> <SmartHistoricalDataImport
<div> tenantId={tenantId}
<h3 className="text-lg font-semibold text-gray-900"> onComplete={(result) => {
Datos Históricos (Modo Tradicional) // Mark sales data as uploaded and proceed to training
</h3> completeStep('sales_data_uploaded', {
<p className="text-gray-600 mt-1"> smart_import: true,
Sube tus datos y configura tu inventario manualmente records_imported: result.successful_imports,
</p> import_job_id: result.import_job_id,
</div> tenant_id: tenantId,
user_id: user?.id
<button }).then(() => {
onClick={() => setUseSmartImport(true)} setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
className="flex items-center space-x-2 px-4 py-2 text-sm bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg hover:from-blue-600 hover:to-purple-600 transition-colors" startTraining();
> }).catch(() => {
<Brain className="w-4 h-4" /> // Continue even if step completion fails
<span>Activar IA</span> setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
</button> startTraining();
</div> });
}}
<p className="text-gray-600 mb-6"> />
Para obtener predicciones precisas, necesitamos tus datos históricos de ventas.
Puedes subir archivos en varios formatos.
</p>
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg mb-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">!</span>
</div>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-blue-900">
Formatos soportados y estructura de datos
</h4>
<div className="mt-2 text-sm text-blue-700">
<p className="mb-3"><strong>Formatos aceptados:</strong></p>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="font-medium">📊 Hojas de cálculo:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.xlsx (Excel moderno)</li>
<li>.xls (Excel clásico)</li>
</ul>
</div>
<div>
<p className="font-medium">📄 Datos estructurados:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.csv (Valores separados por comas)</li>
<li>.json (Formato JSON)</li>
</ul>
</div>
</div>
<p className="mb-2"><strong>Columnas requeridas (en cualquier idioma):</strong></p>
<ul className="list-disc list-inside text-xs space-y-1">
<li><strong>Fecha</strong>: fecha, date, datum (formato: YYYY-MM-DD, DD/MM/YYYY, etc.)</li>
<li><strong>Producto</strong>: producto, product, item, articulo, nombre</li>
<li><strong>Cantidad</strong>: cantidad, quantity, cantidad_vendida, qty</li>
</ul>
</div>
</div>
</div>
</div>
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-xl hover:border-primary-300 transition-colors">
<div className="text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<label htmlFor="sales-file-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-medium text-gray-900">
Subir archivo de datos históricos
</span>
<span className="mt-1 block text-sm text-gray-500">
Arrastra y suelta tu archivo aquí, o haz clic para seleccionar
</span>
<span className="mt-1 block text-xs text-gray-400">
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
</span>
</label>
<input
id="sales-file-upload"
type="file"
accept=".csv,.xlsx,.xls,.json"
required
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
toast.error('El archivo es demasiado grande. Máximo 10MB.');
return;
}
// Update bakery data with the selected file
setBakeryData(prev => ({
...prev,
csvFile: file,
hasHistoricalData: true
}));
toast.success(`Archivo ${file.name} seleccionado correctamente`);
// Auto-validate the file after upload if tenantId exists
if (tenantId) {
setValidationStatus({ status: 'validating' });
try {
const validationResult = await validateSalesData(tenantId, file);
if (validationResult.is_valid) {
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
} else {
// If no tenantId yet, set to idle and wait for manual validation
setValidationStatus({ status: 'idle' });
}
}
}}
className="hidden"
/>
</div>
</div>
{bakeryData.csvFile ? (
<div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
</div>
</div>
<button
onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
</div>
</div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-yellow-600 mr-2" />
<p className="text-sm text-yellow-800">
<strong>Archivo requerido:</strong> Selecciona un archivo con tus datos históricos de ventas
</p>
</div>
</div>
)}
</div>
{/* Show switch to smart import suggestion if traditional validation fails */}
{validationStatus.status === 'invalid' && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Brain className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900 mb-2">
💡 ¿Problemas con la validación?
</h4>
<p className="text-sm text-blue-700 mb-3">
Nuestra IA puede manejar archivos con formatos más flexibles y ayudarte a solucionar problemas automáticamente.
</p>
<button
onClick={() => setUseSmartImport(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Probar importación inteligente
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
@@ -1369,7 +908,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</main> </main>
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3), completion (step 4), and smart import */} {/* Navigation with Enhanced Accessibility - Hidden during training (step 3), completion (step 4), and smart import */}
{currentStep < 3 && !(currentStep === 2 && useSmartImport) && ( {currentStep < 3 && currentStep !== 2 && (
<nav <nav
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6" className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
role="navigation" role="navigation"
@@ -1391,16 +930,15 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
<div className="text-sm text-gray-500">Paso {currentStep} de 4</div> <div className="text-sm text-gray-500">Paso {currentStep} de 4</div>
<div className="text-xs text-gray-400 mt-1"> <div className="text-xs text-gray-400 mt-1">
{currentStep === 1 && "Configura tu panadería"} {currentStep === 1 && "Configura tu panadería"}
{currentStep === 2 && "Sube datos de ventas"}
</div> </div>
</div> </div>
{/* Dynamic Next Button with contextual text */} {/* Dynamic Next Button with contextual text */}
<button <button
onClick={handleNext} onClick={handleNext}
disabled={isLoading || (currentStep === 2 && validationStatus?.status !== 'valid')} disabled={isLoading}
className="flex items-center px-8 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all hover:shadow-lg hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2" className="flex items-center px-8 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all hover:shadow-lg hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
aria-label={`${currentStep === 1 ? 'Save bakery information and continue' : currentStep === 2 && validationStatus?.status === 'valid' ? 'Start AI training' : 'Continue to next step'}`} aria-label={`${currentStep === 1 ? 'Save bakery information and continue' : 'Continue to next step'}`}
> >
{isLoading ? ( {isLoading ? (
<> <>
@@ -1412,11 +950,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
) : ( ) : (
<> <>
{currentStep === 1 && "Guardar y Continuar"} {currentStep === 1 && "Guardar y Continuar"}
{currentStep === 2 && (
<>
{validationStatus?.status === 'valid' ? "Iniciar Entrenamiento" : "Continuar"}
</>
)}
<ChevronRight className="h-5 w-5 ml-2 group-hover:translate-x-0.5 transition-transform" aria-hidden="true" /> <ChevronRight className="h-5 w-5 ml-2 group-hover:translate-x-0.5 transition-transform" aria-hidden="true" />
</> </>
)} )}

View File

@@ -5,7 +5,7 @@ Handles routing, authentication, rate limiting, and cross-cutting concerns
import asyncio import asyncio
import structlog import structlog
from fastapi import FastAPI, Request, HTTPException, Depends from fastapi import FastAPI, Request, HTTPException, Depends, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import httpx import httpx
@@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.middleware.logging import LoggingMiddleware from app.middleware.logging import LoggingMiddleware
from app.middleware.rate_limit import RateLimitMiddleware from app.middleware.rate_limit import RateLimitMiddleware
from app.routes import auth, tenant, notification, nominatim, user, inventory from app.routes import auth, tenant, notification, nominatim, user
from shared.monitoring.logging import setup_logging from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
@@ -60,7 +60,6 @@ app.include_router(user.router, prefix="/api/v1/users", tags=["users"])
app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"]) app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"]) app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
app.include_router(inventory.router, prefix="/api/v1/inventory", tags=["inventory"])
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
@@ -117,6 +116,92 @@ async def metrics():
"""Metrics endpoint for monitoring""" """Metrics endpoint for monitoring"""
return {"metrics": "enabled"} return {"metrics": "enabled"}
# ================================================================
# WEBSOCKET ROUTING FOR TRAINING SERVICE
# ================================================================
@app.websocket("/api/v1/ws/tenants/{tenant_id}/training/jobs/{job_id}/live")
async def websocket_training_progress(websocket: WebSocket, tenant_id: str, job_id: str):
"""WebSocket proxy for training progress updates"""
await websocket.accept()
# Get token from query params
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=1008, reason="Authentication token required")
return
# Build HTTP URL to training service (we'll use HTTP client to proxy)
training_service_base = settings.TRAINING_SERVICE_URL.rstrip('/')
training_ws_url = f"{training_service_base}/api/v1/ws/tenants/{tenant_id}/training/jobs/{job_id}/live?token={token}"
try:
# Use HTTP client to connect to training service WebSocket
async with httpx.AsyncClient() as client:
# Since we can't easily proxy WebSocket with httpx, let's try a different approach
# We'll make periodic HTTP requests to get training status
logger.info(f"Starting WebSocket proxy for training job {job_id}")
# Send initial connection confirmation
await websocket.send_json({
"type": "connection_established",
"job_id": job_id,
"tenant_id": tenant_id
})
# Poll for training updates
last_status = None
while True:
try:
# Make HTTP request to get current training status
status_url = f"{training_service_base}/api/v1/tenants/{tenant_id}/training/jobs/{job_id}/status"
response = await client.get(
status_url,
headers={"Authorization": f"Bearer {token}"},
timeout=5.0
)
if response.status_code == 200:
current_status = response.json()
# Only send update if status changed
if current_status != last_status:
await websocket.send_json({
"type": "training_progress",
"data": current_status
})
last_status = current_status
# If training is completed or failed, we can stop polling
if current_status.get('status') in ['completed', 'failed', 'cancelled']:
await websocket.send_json({
"type": "training_" + current_status.get('status', 'completed'),
"data": current_status
})
break
# Wait before next poll
await asyncio.sleep(2)
except WebSocketDisconnect:
logger.info("WebSocket client disconnected")
break
except httpx.TimeoutException:
# Continue polling even if request times out
await asyncio.sleep(5)
continue
except Exception as e:
logger.error(f"Error polling training status: {e}")
await asyncio.sleep(5)
continue
except WebSocketDisconnect:
logger.info("WebSocket client disconnected during setup")
except Exception as e:
logger.error(f"WebSocket proxy error: {e}")
await websocket.close(code=1011, reason="Internal server error")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -1,216 +0,0 @@
# gateway/app/routes/inventory.py
"""
Inventory routes for API Gateway - Handles inventory management endpoints
"""
from fastapi import APIRouter, Request, Response, HTTPException, Path
from fastapi.responses import JSONResponse
import httpx
import logging
from typing import Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
# Inventory service URL - add to settings
INVENTORY_SERVICE_URL = "http://inventory-service:8000"
# ================================================================
# TENANT-SCOPED INVENTORY ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/inventory/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant ingredient requests to inventory service"""
base_path = f"/api/v1/ingredients"
# If path is empty or just "/", use base path
if not path or path == "/" or path == "":
target_path = base_path
else:
# Ensure path starts with "/"
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/{tenant_id}/inventory/stock{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_stock(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant stock requests to inventory service"""
base_path = f"/api/v1/stock"
# If path is empty or just "/", use base path
if not path or path == "/" or path == "":
target_path = base_path
else:
# Ensure path starts with "/"
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/{tenant_id}/inventory/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alerts(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory alert requests to inventory service"""
base_path = f"/api/v1/alerts"
# If path is empty or just "/", use base path
if not path or path == "/" or path == "":
target_path = base_path
else:
# Ensure path starts with "/"
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/{tenant_id}/inventory/dashboard{path:path}", methods=["GET", "OPTIONS"])
async def proxy_tenant_inventory_dashboard(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory dashboard requests to inventory service"""
base_path = f"/api/v1/dashboard"
# If path is empty or just "/", use base path
if not path or path == "/" or path == "":
target_path = base_path
else:
# Ensure path starts with "/"
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
# ================================================================
# DIRECT INVENTORY ENDPOINTS (for backward compatibility)
# ================================================================
@router.api_route("/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_ingredients(request: Request, path: str = ""):
"""Proxy ingredient requests to inventory service"""
base_path = f"/api/v1/ingredients"
if not path or path == "/" or path == "":
target_path = base_path
else:
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/stock{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_stock(request: Request, path: str = ""):
"""Proxy stock requests to inventory service"""
base_path = f"/api/v1/stock"
if not path or path == "/" or path == "":
target_path = base_path
else:
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_alerts(request: Request, path: str = ""):
"""Proxy inventory alert requests to inventory service"""
base_path = f"/api/v1/alerts"
if not path or path == "/" or path == "":
target_path = base_path
else:
if not path.startswith("/"):
path = "/" + path
target_path = base_path + path
return await _proxy_to_inventory_service(request, target_path)
# ================================================================
# PROXY HELPER FUNCTION
# ================================================================
async def _proxy_to_inventory_service(request: Request, target_path: str):
"""Proxy request to inventory service with enhanced error handling"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{INVENTORY_SERVICE_URL}{target_path}"
# Forward headers and add user/tenant context
headers = dict(request.headers)
headers.pop("host", None)
# Get request body if present
body = None
if request.method in ["POST", "PUT", "PATCH"]:
body = await request.body()
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0, # Connection timeout
read=600.0, # Read timeout: 10 minutes
write=30.0, # Write timeout
pool=30.0 # Pool timeout
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from inventory service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except httpx.ConnectTimeout:
logger.error(f"Connection timeout to inventory service: {INVENTORY_SERVICE_URL}{target_path}")
raise HTTPException(
status_code=503,
detail="Inventory service temporarily unavailable"
)
except httpx.ReadTimeout:
logger.error(f"Read timeout from inventory service: {INVENTORY_SERVICE_URL}{target_path}")
raise HTTPException(
status_code=504,
detail="Inventory service response timeout"
)
except Exception as e:
logger.error(f"Unexpected error proxying to inventory service {INVENTORY_SERVICE_URL}{target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

View File

@@ -134,6 +134,26 @@ async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...
target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/") target_path = f"/api/v1/tenants/{tenant_id}/notifications/{path}".rstrip("/")
return await _proxy_to_notification_service(request, target_path) return await _proxy_to_notification_service(request, target_path)
# ================================================================
# TENANT-SCOPED INVENTORY SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/inventory/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_inventory(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory requests to inventory service"""
# The inventory service expects /api/v1/tenants/{tenant_id}/inventory/{path}
# Keep the full path structure for inventory service
target_path = f"/api/v1/tenants/{tenant_id}/inventory/{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path)
@router.api_route("/{tenant_id}/ingredients{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_ingredients(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant ingredient requests to inventory service"""
# The inventory service ingredient endpoints are now tenant-scoped: /api/v1/tenants/{tenant_id}/ingredients/{path}
# Keep the full tenant path structure
target_path = f"/api/v1/tenants/{tenant_id}/ingredients{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path)
# ================================================================ # ================================================================
# PROXY HELPER FUNCTIONS # PROXY HELPER FUNCTIONS
# ================================================================ # ================================================================
@@ -162,6 +182,10 @@ async def _proxy_to_notification_service(request: Request, target_path: str):
"""Proxy request to notification service""" """Proxy request to notification service"""
return await _proxy_request(request, target_path, settings.NOTIFICATION_SERVICE_URL) return await _proxy_request(request, target_path, settings.NOTIFICATION_SERVICE_URL)
async def _proxy_to_inventory_service(request: Request, target_path: str):
"""Proxy request to inventory service"""
return await _proxy_request(request, target_path, settings.INVENTORY_SERVICE_URL)
async def _proxy_request(request: Request, target_path: str, service_url: str): async def _proxy_request(request: Request, target_path: str, service_url: str):
"""Generic proxy function with enhanced error handling""" """Generic proxy function with enhanced error handling"""

View File

@@ -5,8 +5,8 @@ AI-powered product classification for onboarding automation
""" """
from fastapi import APIRouter, Depends, HTTPException, Path from fastapi import APIRouter, Depends, HTTPException, Path
from typing import List, Dict, Any from typing import List, Dict, Any, Optional
from uuid import UUID from uuid import UUID, uuid4
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import structlog import structlog
@@ -38,12 +38,12 @@ class ProductSuggestionResponse(BaseModel):
category: str category: str
unit_of_measure: str unit_of_measure: str
confidence_score: float confidence_score: float
estimated_shelf_life_days: int = None estimated_shelf_life_days: Optional[int] = None
requires_refrigeration: bool = False requires_refrigeration: bool = False
requires_freezing: bool = False requires_freezing: bool = False
is_seasonal: bool = False is_seasonal: bool = False
suggested_supplier: str = None suggested_supplier: Optional[str] = None
notes: str = None notes: Optional[str] = None
class BusinessModelAnalysisResponse(BaseModel): class BusinessModelAnalysisResponse(BaseModel):
@@ -87,7 +87,7 @@ async def classify_single_product(
# Convert to response format # Convert to response format
response = ProductSuggestionResponse( response = ProductSuggestionResponse(
suggestion_id=str(UUID.uuid4()), # Generate unique ID for tracking suggestion_id=str(uuid4()), # Generate unique ID for tracking
original_name=suggestion.original_name, original_name=suggestion.original_name,
suggested_name=suggestion.suggested_name, suggested_name=suggestion.suggested_name,
product_type=suggestion.product_type.value, product_type=suggestion.product_type.value,
@@ -144,7 +144,7 @@ async def classify_products_batch(
suggestion_responses = [] suggestion_responses = []
for suggestion in suggestions: for suggestion in suggestions:
suggestion_responses.append(ProductSuggestionResponse( suggestion_responses.append(ProductSuggestionResponse(
suggestion_id=str(UUID.uuid4()), suggestion_id=str(uuid4()),
original_name=suggestion.original_name, original_name=suggestion.original_name,
suggested_name=suggestion.suggested_name, suggested_name=suggestion.suggested_name,
product_type=suggestion.product_type.value, product_type=suggestion.product_type.value,
@@ -159,39 +159,58 @@ async def classify_products_batch(
notes=suggestion.notes notes=suggestion.notes
)) ))
# Analyze business model # Analyze business model with enhanced detection
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient') ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient')
finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product') finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product')
semi_finished_count = sum(1 for s in suggestions if 'semi' in s.suggested_name.lower() or 'frozen' in s.suggested_name.lower() or 'pre' in s.suggested_name.lower())
total = len(suggestions) total = len(suggestions)
ingredient_ratio = ingredient_count / total if total > 0 else 0 ingredient_ratio = ingredient_count / total if total > 0 else 0
semi_finished_ratio = semi_finished_count / total if total > 0 else 0
# Determine business model # Enhanced business model determination
if ingredient_ratio >= 0.7: if ingredient_ratio >= 0.7:
model = 'production' model = 'individual_bakery' # Full production from raw ingredients
elif ingredient_ratio <= 0.2 and semi_finished_ratio >= 0.3:
model = 'central_baker_satellite' # Receives semi-finished products from central baker
elif ingredient_ratio <= 0.3: elif ingredient_ratio <= 0.3:
model = 'retail' model = 'retail_bakery' # Sells finished products from suppliers
else: else:
model = 'hybrid' model = 'hybrid_bakery' # Mixed model
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1) # Calculate confidence based on clear distinction
if model == 'individual_bakery':
confidence = min(ingredient_ratio * 1.2, 0.95)
elif model == 'central_baker_satellite':
confidence = min((semi_finished_ratio + (1 - ingredient_ratio)) / 2 * 1.2, 0.95)
else:
confidence = max(abs(ingredient_ratio - 0.5) * 2, 0.1)
recommendations = { recommendations = {
'production': [ 'individual_bakery': [
'Focus on ingredient inventory management', 'Set up raw ingredient inventory management',
'Set up recipe cost calculation', 'Configure recipe cost calculation and production planning',
'Configure supplier relationships', 'Enable supplier relationships for flour, yeast, sugar, etc.',
'Enable production planning features' 'Set up full production workflow with proofing and baking schedules',
'Enable waste tracking for overproduction'
], ],
'retail': [ 'central_baker_satellite': [
'Configure central baker relationships', 'Configure central baker delivery schedules',
'Set up delivery schedule tracking', 'Set up semi-finished product inventory (frozen dough, par-baked items)',
'Enable finished product freshness monitoring', 'Enable finish-baking workflow and timing optimization',
'Focus on sales forecasting' 'Track freshness and shelf-life for received products',
'Focus on customer demand forecasting for final products'
], ],
'hybrid': [ 'retail_bakery': [
'Configure both ingredient and finished product management', 'Set up finished product supplier relationships',
'Set up flexible inventory categories', 'Configure delivery schedule tracking',
'Enable both production and retail features' 'Enable freshness monitoring and expiration management',
'Focus on sales forecasting and customer preferences'
],
'hybrid_bakery': [
'Configure both ingredient and semi-finished product management',
'Set up flexible production workflows',
'Enable both supplier and central baker relationships',
'Configure multi-tier inventory categories'
] ]
} }

View File

@@ -5,7 +5,7 @@ API endpoints for ingredient management
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
@@ -17,10 +17,9 @@ from app.schemas.inventory import (
InventoryFilter, InventoryFilter,
PaginatedResponse PaginatedResponse
) )
from shared.auth.decorators import get_current_user_dep from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
from shared.auth.tenant_access import verify_tenant_access_dep
router = APIRouter(prefix="/ingredients", tags=["ingredients"]) router = APIRouter(tags=["ingredients"])
# Helper function to extract user ID from user object # Helper function to extract user ID from user object
def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID: def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> UUID:
@@ -34,15 +33,31 @@ def get_current_user_id(current_user: dict = Depends(get_current_user_dep)) -> U
return UUID(user_id) return UUID(user_id)
@router.post("/", response_model=IngredientResponse) @router.post("/tenants/{tenant_id}/ingredients", response_model=IngredientResponse)
async def create_ingredient( async def create_ingredient(
ingredient_data: IngredientCreate, ingredient_data: IngredientCreate,
tenant_id: UUID = Depends(verify_tenant_access_dep), tenant_id: UUID = Path(..., description="Tenant ID"),
user_id: UUID = Depends(get_current_user_id), current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Create a new ingredient""" """Create a new ingredient"""
try: try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
# Extract user ID - handle service tokens that don't have UUID user_ids
raw_user_id = current_user.get('user_id')
if current_user.get('type') == 'service':
# For service tokens, user_id might not be a UUID, so set to None
user_id = None
else:
try:
user_id = UUID(raw_user_id)
except (ValueError, TypeError):
user_id = None
service = InventoryService() service = InventoryService()
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id) ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
return ingredient return ingredient
@@ -58,14 +73,20 @@ async def create_ingredient(
) )
@router.get("/{ingredient_id}", response_model=IngredientResponse) @router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
async def get_ingredient( async def get_ingredient(
ingredient_id: UUID, ingredient_id: UUID,
tenant_id: UUID = Depends(verify_tenant_access_dep), tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get ingredient by ID""" """Get ingredient by ID"""
try: try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService() service = InventoryService()
ingredient = await service.get_ingredient(ingredient_id, tenant_id) ingredient = await service.get_ingredient(ingredient_id, tenant_id)
@@ -85,15 +106,21 @@ async def get_ingredient(
) )
@router.put("/{ingredient_id}", response_model=IngredientResponse) @router.put("/tenants/{tenant_id}/ingredients/{ingredient_id}", response_model=IngredientResponse)
async def update_ingredient( async def update_ingredient(
ingredient_id: UUID, ingredient_id: UUID,
ingredient_data: IngredientUpdate, ingredient_data: IngredientUpdate,
tenant_id: UUID = Depends(verify_tenant_access_dep), tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Update ingredient""" """Update ingredient"""
try: try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService() service = InventoryService()
ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id) ingredient = await service.update_ingredient(ingredient_id, ingredient_data, tenant_id)
@@ -118,8 +145,9 @@ async def update_ingredient(
) )
@router.get("/", response_model=List[IngredientResponse]) @router.get("/tenants/{tenant_id}/ingredients", response_model=List[IngredientResponse])
async def list_ingredients( async def list_ingredients(
tenant_id: UUID = Path(..., description="Tenant ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"), skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"), limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
category: Optional[str] = Query(None, description="Filter by category"), category: Optional[str] = Query(None, description="Filter by category"),
@@ -127,11 +155,16 @@ async def list_ingredients(
is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"), is_low_stock: Optional[bool] = Query(None, description="Filter by low stock status"),
needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"), needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"),
search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"), search: Optional[str] = Query(None, description="Search in name, SKU, or barcode"),
tenant_id: UUID = Depends(verify_tenant_access_dep), current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""List ingredients with filtering""" """List ingredients with filtering"""
try: try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService() service = InventoryService()
# Build filters # Build filters
@@ -156,14 +189,20 @@ async def list_ingredients(
) )
@router.delete("/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/tenants/{tenant_id}/ingredients/{ingredient_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ingredient( async def delete_ingredient(
ingredient_id: UUID, ingredient_id: UUID,
tenant_id: UUID = Depends(verify_tenant_access_dep), tenant_id: UUID = Path(..., description="Tenant ID"),
current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Soft delete ingredient (mark as inactive)""" """Soft delete ingredient (mark as inactive)"""
try: try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService() service = InventoryService()
ingredient = await service.update_ingredient( ingredient = await service.update_ingredient(
ingredient_id, ingredient_id,
@@ -187,15 +226,21 @@ async def delete_ingredient(
) )
@router.get("/{ingredient_id}/stock", response_model=List[dict]) @router.get("/tenants/{tenant_id}/ingredients/{ingredient_id}/stock", response_model=List[dict])
async def get_ingredient_stock( async def get_ingredient_stock(
ingredient_id: UUID, ingredient_id: UUID,
tenant_id: UUID = Path(..., description="Tenant ID"),
include_unavailable: bool = Query(False, description="Include unavailable stock"), include_unavailable: bool = Query(False, description="Include unavailable stock"),
tenant_id: UUID = Depends(verify_tenant_access_dep), current_tenant: str = Depends(get_current_tenant_id_dep),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get stock entries for an ingredient""" """Get stock entries for an ingredient"""
try: try:
# Verify tenant access
if str(tenant_id) != current_tenant:
raise HTTPException(status_code=403, detail="Access denied to this tenant")
service = InventoryService() service = InventoryService()
stock_entries = await service.get_stock_by_ingredient( stock_entries = await service.get_stock_by_ingredient(
ingredient_id, tenant_id, include_unavailable ingredient_id, tenant_id, include_unavailable

View File

@@ -160,6 +160,14 @@ class Ingredient(Base):
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert model to dictionary for API responses""" """Convert model to dictionary for API responses"""
# Map to response schema format - use ingredient_category as primary category
category = None
if self.ingredient_category:
category = self.ingredient_category.value
elif self.product_category:
# For finished products, we could map to a generic category
category = "other"
return { return {
'id': str(self.id), 'id': str(self.id),
'tenant_id': str(self.tenant_id), 'tenant_id': str(self.tenant_id),
@@ -167,6 +175,7 @@ class Ingredient(Base):
'sku': self.sku, 'sku': self.sku,
'barcode': self.barcode, 'barcode': self.barcode,
'product_type': self.product_type.value if self.product_type else None, 'product_type': self.product_type.value if self.product_type else None,
'category': category, # Map to what IngredientResponse expects
'ingredient_category': self.ingredient_category.value if self.ingredient_category else None, 'ingredient_category': self.ingredient_category.value if self.ingredient_category else None,
'product_category': self.product_category.value if self.product_category else None, 'product_category': self.product_category.value if self.product_category else None,
'subcategory': self.subcategory, 'subcategory': self.subcategory,

View File

@@ -26,17 +26,53 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient: async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient:
"""Create a new ingredient""" """Create a new ingredient"""
try: try:
# Prepare data # Prepare data and map schema fields to model fields
create_data = ingredient_data.model_dump() create_data = ingredient_data.model_dump()
create_data['tenant_id'] = tenant_id create_data['tenant_id'] = tenant_id
# Map 'category' from schema to appropriate model fields
if 'category' in create_data:
category_value = create_data.pop('category')
# For now, assume all items are ingredients and map to ingredient_category
# Convert string to enum object
from app.models.inventory import IngredientCategory
try:
# Find the enum member by value
for enum_member in IngredientCategory:
if enum_member.value == category_value:
create_data['ingredient_category'] = enum_member
break
else:
# If not found, default to OTHER
create_data['ingredient_category'] = IngredientCategory.OTHER
except Exception:
# Fallback to OTHER if any issues
create_data['ingredient_category'] = IngredientCategory.OTHER
# Convert unit_of_measure string to enum object
if 'unit_of_measure' in create_data:
unit_value = create_data['unit_of_measure']
from app.models.inventory import UnitOfMeasure
try:
# Find the enum member by value
for enum_member in UnitOfMeasure:
if enum_member.value == unit_value:
create_data['unit_of_measure'] = enum_member
break
else:
# If not found, default to UNITS
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
except Exception:
# Fallback to UNITS if any issues
create_data['unit_of_measure'] = UnitOfMeasure.UNITS
# Create record # Create record
record = await self.create(create_data) record = await self.create(create_data)
logger.info( logger.info(
"Created ingredient", "Created ingredient",
ingredient_id=record.id, ingredient_id=record.id,
name=record.name, name=record.name,
category=record.category.value if record.category else None, ingredient_category=record.ingredient_category.value if record.ingredient_category else None,
tenant_id=tenant_id tenant_id=tenant_id
) )
return record return record

View File

@@ -25,6 +25,27 @@ logger = structlog.get_logger()
class FileValidationResponse(BaseModel):
"""Response for file validation step"""
is_valid: bool
total_records: int
unique_products: int
product_list: List[str]
validation_errors: List[Any]
validation_warnings: List[Any]
summary: Dict[str, Any]
class ProductSuggestionsResponse(BaseModel):
"""Response for AI suggestions step"""
suggestions: List[Dict[str, Any]]
business_model_analysis: Dict[str, Any]
total_products: int
high_confidence_count: int
low_confidence_count: int
processing_time_seconds: float
class InventoryApprovalRequest(BaseModel): class InventoryApprovalRequest(BaseModel):
"""Request to approve/modify inventory suggestions""" """Request to approve/modify inventory suggestions"""
suggestions: List[Dict[str, Any]] = Field(..., description="Approved suggestions with modifications") suggestions: List[Dict[str, Any]] = Field(..., description="Approved suggestions with modifications")

View File

@@ -310,6 +310,19 @@ class AIOnboardingService:
processing_time_seconds=processing_time processing_time_seconds=processing_time
) )
# Update tenant's business model based on AI analysis
if business_model.model != "unknown" and business_model.confidence >= 0.6:
try:
await self._update_tenant_business_model(tenant_id, business_model.model)
logger.info("Updated tenant business model",
tenant_id=tenant_id,
business_model=business_model.model,
confidence=business_model.confidence)
except Exception as e:
logger.warning("Failed to update tenant business model",
error=str(e), tenant_id=tenant_id)
# Don't fail the entire process if tenant update fails
logger.info("AI inventory suggestions completed", logger.info("AI inventory suggestions completed",
total_suggestions=len(suggestions), total_suggestions=len(suggestions),
business_model=business_model.model, business_model=business_model.model,
@@ -385,21 +398,68 @@ class AIOnboardingService:
try: try:
# Build inventory item data from suggestion and modifications # Build inventory item data from suggestion and modifications
# Map to inventory service expected format
raw_category = modifications.get("category") or approval.get("category", "other")
raw_unit = modifications.get("unit_of_measure") or approval.get("unit_of_measure", "units")
# Map categories to inventory service enum values
category_mapping = {
"flour": "flour",
"yeast": "yeast",
"dairy": "dairy",
"eggs": "eggs",
"sugar": "sugar",
"fats": "fats",
"salt": "salt",
"spices": "spices",
"additives": "additives",
"packaging": "packaging",
"cleaning": "cleaning",
"grains": "flour", # Map common variations
"bread": "other",
"pastries": "other",
"croissants": "other",
"cakes": "other",
"other_products": "other"
}
# Map units to inventory service enum values
unit_mapping = {
"kg": "kg",
"kilograms": "kg",
"g": "g",
"grams": "g",
"l": "l",
"liters": "l",
"ml": "ml",
"milliliters": "ml",
"units": "units",
"pieces": "pcs",
"pcs": "pcs",
"packages": "pkg",
"pkg": "pkg",
"bags": "bags",
"boxes": "boxes"
}
mapped_category = category_mapping.get(raw_category.lower(), "other")
mapped_unit = unit_mapping.get(raw_unit.lower(), "units")
inventory_data = { inventory_data = {
"name": modifications.get("name") or approval.get("suggested_name"), "name": modifications.get("name") or approval.get("suggested_name"),
"product_type": modifications.get("product_type") or approval.get("product_type"), "category": mapped_category,
"category": modifications.get("category") or approval.get("category"), "unit_of_measure": mapped_unit,
"unit_of_measure": modifications.get("unit_of_measure") or approval.get("unit_of_measure"),
"description": modifications.get("description") or approval.get("notes", ""), "description": modifications.get("description") or approval.get("notes", ""),
"estimated_shelf_life_days": modifications.get("estimated_shelf_life_days") or approval.get("estimated_shelf_life_days"), # Optional fields
"requires_refrigeration": modifications.get("requires_refrigeration", approval.get("requires_refrigeration", False)), "brand": modifications.get("brand") or approval.get("suggested_supplier"),
"requires_freezing": modifications.get("requires_freezing", approval.get("requires_freezing", False)), "is_active": True
"is_seasonal": modifications.get("is_seasonal", approval.get("is_seasonal", False)),
"suggested_supplier": modifications.get("suggested_supplier") or approval.get("suggested_supplier"),
"is_active": True,
"source": "ai_onboarding"
} }
# Add optional numeric fields only if they exist
shelf_life = modifications.get("estimated_shelf_life_days") or approval.get("estimated_shelf_life_days")
if shelf_life:
inventory_data["shelf_life_days"] = shelf_life
# Create inventory item via inventory service # Create inventory item via inventory service
created_item = await self.inventory_client.create_ingredient( created_item = await self.inventory_client.create_ingredient(
inventory_data, str(tenant_id) inventory_data, str(tenant_id)
@@ -619,6 +679,47 @@ class AIOnboardingService:
except Exception as e: except Exception as e:
logger.warning("Failed to analyze product sales data", error=str(e)) logger.warning("Failed to analyze product sales data", error=str(e))
return {} return {}
async def _update_tenant_business_model(self, tenant_id: UUID, business_model: str) -> None:
"""Update tenant's business model based on AI analysis"""
try:
# Use the gateway URL for all inter-service communication
from app.core.config import settings
import httpx
gateway_url = settings.GATEWAY_URL
url = f"{gateway_url}/api/v1/tenants/{tenant_id}"
# Prepare update data
update_data = {
"business_model": business_model
}
# Make request through gateway
timeout_config = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.put(
url,
json=update_data,
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
logger.info("Successfully updated tenant business model via gateway",
tenant_id=tenant_id, business_model=business_model)
else:
logger.warning("Failed to update tenant business model via gateway",
tenant_id=tenant_id,
status_code=response.status_code,
response=response.text)
except Exception as e:
logger.error("Error updating tenant business model via gateway",
tenant_id=tenant_id,
business_model=business_model,
error=str(e))
raise
# Factory function for dependency injection # Factory function for dependency injection

View File

@@ -11,13 +11,16 @@ import base64
import pandas as pd import pandas as pd
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID
import structlog import structlog
import re import re
import asyncio
from app.repositories.sales_repository import SalesRepository from app.repositories.sales_repository import SalesRepository
from app.models.sales import SalesData from app.models.sales import SalesData
from app.schemas.sales import SalesDataCreate from app.schemas.sales import SalesDataCreate
from app.core.database import get_db_transaction from app.core.database import get_db_transaction
from app.services.inventory_client import InventoryServiceClient
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -79,7 +82,10 @@ class DataImportService:
def __init__(self): def __init__(self):
"""Initialize enhanced import service""" """Initialize enhanced import service"""
pass self.inventory_client = InventoryServiceClient()
# Product resolution cache for the import session
self.product_cache = {} # product_name -> inventory_product_id
self.failed_products = set() # Track products that failed to resolve
async def validate_import_data(self, data: Dict[str, Any]) -> SalesValidationResult: async def validate_import_data(self, data: Dict[str, Any]) -> SalesValidationResult:
"""Enhanced validation with better error handling and suggestions""" """Enhanced validation with better error handling and suggestions"""
@@ -349,6 +355,9 @@ class DataImportService:
start_time = datetime.utcnow() start_time = datetime.utcnow()
try: try:
# Clear cache for new import session
self._clear_import_cache()
logger.info("Starting enhanced data import", logger.info("Starting enhanced data import",
filename=filename, filename=filename,
format=file_format, format=file_format,
@@ -451,12 +460,24 @@ class DataImportService:
warnings.extend(parsed_data.get("warnings", [])) warnings.extend(parsed_data.get("warnings", []))
continue continue
# Resolve product name to inventory_product_id
inventory_product_id = await self._resolve_product_to_inventory_id(
parsed_data["product_name"],
parsed_data.get("product_category"),
tenant_id
)
if not inventory_product_id:
error_msg = f"Row {index + 1}: Could not resolve product '{parsed_data['product_name']}' to inventory ID"
errors.append(error_msg)
logger.warning("Product resolution failed", error=error_msg)
continue
# Create sales record with enhanced data # Create sales record with enhanced data
sales_data = SalesDataCreate( sales_data = SalesDataCreate(
tenant_id=tenant_id, tenant_id=tenant_id,
date=parsed_data["date"], date=parsed_data["date"],
product_name=parsed_data["product_name"], inventory_product_id=inventory_product_id,
product_category=parsed_data.get("product_category"),
quantity_sold=parsed_data["quantity_sold"], quantity_sold=parsed_data["quantity_sold"],
unit_price=parsed_data.get("unit_price"), unit_price=parsed_data.get("unit_price"),
revenue=parsed_data.get("revenue"), revenue=parsed_data.get("revenue"),
@@ -619,12 +640,24 @@ class DataImportService:
warnings.extend(parsed_data.get("warnings", [])) warnings.extend(parsed_data.get("warnings", []))
continue continue
# Resolve product name to inventory_product_id
inventory_product_id = await self._resolve_product_to_inventory_id(
parsed_data["product_name"],
parsed_data.get("product_category"),
tenant_id
)
if not inventory_product_id:
error_msg = f"Row {index + 1}: Could not resolve product '{parsed_data['product_name']}' to inventory ID"
errors.append(error_msg)
logger.warning("Product resolution failed", error=error_msg)
continue
# Create enhanced sales record # Create enhanced sales record
sales_data = SalesDataCreate( sales_data = SalesDataCreate(
tenant_id=tenant_id, tenant_id=tenant_id,
date=parsed_data["date"], date=parsed_data["date"],
product_name=parsed_data["product_name"], inventory_product_id=inventory_product_id,
product_category=parsed_data.get("product_category"),
quantity_sold=parsed_data["quantity_sold"], quantity_sold=parsed_data["quantity_sold"],
unit_price=parsed_data.get("unit_price"), unit_price=parsed_data.get("unit_price"),
revenue=parsed_data.get("revenue"), revenue=parsed_data.get("revenue"),
@@ -874,6 +907,94 @@ class DataImportService:
return cleaned if cleaned else "Producto sin nombre" return cleaned if cleaned else "Producto sin nombre"
def _clear_import_cache(self):
"""Clear the product resolution cache for a new import session"""
self.product_cache.clear()
self.failed_products.clear()
logger.info("Import cache cleared for new session")
async def _resolve_product_to_inventory_id(self, product_name: str, product_category: Optional[str], tenant_id: UUID) -> Optional[UUID]:
"""Resolve a product name to an inventory_product_id via the inventory service with caching and rate limiting"""
# Check cache first
if product_name in self.product_cache:
logger.debug("Product resolved from cache", product_name=product_name, tenant_id=tenant_id)
return self.product_cache[product_name]
# Skip if this product already failed to resolve
if product_name in self.failed_products:
logger.debug("Skipping previously failed product", product_name=product_name, tenant_id=tenant_id)
return None
max_retries = 3
base_delay = 1.0 # Start with 1 second delay
for attempt in range(max_retries):
try:
# Add delay before API calls to avoid rate limiting
if attempt > 0:
delay = base_delay * (2 ** (attempt - 1)) # Exponential backoff
logger.info(f"Retrying product resolution after {delay}s delay",
product_name=product_name, attempt=attempt, tenant_id=tenant_id)
await asyncio.sleep(delay)
# First try to search for existing product by name
products = await self.inventory_client.search_products(product_name, tenant_id)
if products:
# Return the first matching product's ID
product_id = products[0].get('id')
if product_id:
uuid_id = UUID(str(product_id))
self.product_cache[product_name] = uuid_id # Cache for future use
logger.info("Resolved product to existing inventory ID",
product_name=product_name, product_id=product_id, tenant_id=tenant_id)
return uuid_id
# Add small delay before creation attempt to avoid hitting rate limits
await asyncio.sleep(0.5)
# If not found, create a new ingredient/product in inventory
ingredient_data = {
'name': product_name,
'type': 'finished_product', # Assuming sales are of finished products
'unit': 'unit', # Default unit
'current_stock': 0, # No stock initially
'reorder_point': 0,
'cost_per_unit': 0,
'category': product_category or 'general'
}
created_product = await self.inventory_client.create_ingredient(ingredient_data, str(tenant_id))
if created_product and created_product.get('id'):
product_id = created_product['id']
uuid_id = UUID(str(product_id))
self.product_cache[product_name] = uuid_id # Cache for future use
logger.info("Created new inventory product for sales data",
product_name=product_name, product_id=product_id, tenant_id=tenant_id)
return uuid_id
logger.warning("Failed to resolve or create product in inventory",
product_name=product_name, tenant_id=tenant_id, attempt=attempt)
except Exception as e:
error_str = str(e)
if "429" in error_str or "rate limit" in error_str.lower():
logger.warning("Rate limit hit, retrying",
product_name=product_name, attempt=attempt, error=error_str, tenant_id=tenant_id)
if attempt < max_retries - 1:
continue # Retry with exponential backoff
else:
logger.error("Error resolving product to inventory ID",
error=error_str, product_name=product_name, tenant_id=tenant_id)
break # Don't retry for non-rate-limit errors
# If all retries failed, mark as failed and return None
self.failed_products.add(product_name)
logger.error("Failed to resolve product after all retries",
product_name=product_name, tenant_id=tenant_id)
return None
def _structure_messages(self, messages: List[Union[str, Dict]]) -> List[Dict[str, Any]]: def _structure_messages(self, messages: List[Union[str, Dict]]) -> List[Dict[str, Any]]:
"""Convert string messages to structured format""" """Convert string messages to structured format"""
structured = [] structured = []

View File

@@ -117,6 +117,20 @@ class InventoryServiceClient:
logger.error("Error fetching products by category", logger.error("Error fetching products by category",
error=str(e), category=category, tenant_id=tenant_id) error=str(e), category=category, tenant_id=tenant_id)
return [] return []
async def create_ingredient(self, ingredient_data: Dict[str, Any], tenant_id: str) -> Optional[Dict[str, Any]]:
"""Create a new ingredient/product in inventory service"""
try:
result = await self._shared_client.create_ingredient(ingredient_data, tenant_id)
if result:
logger.info("Created ingredient in inventory service",
ingredient_name=ingredient_data.get('name'), tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error creating ingredient",
error=str(e), ingredient_data=ingredient_data, tenant_id=tenant_id)
return None
# Dependency injection # Dependency injection
async def get_inventory_client() -> InventoryServiceClient: async def get_inventory_client() -> InventoryServiceClient:

View File

@@ -20,6 +20,7 @@ class Tenant(Base):
name = Column(String(200), nullable=False) name = Column(String(200), nullable=False)
subdomain = Column(String(100), unique=True) subdomain = Column(String(100), unique=True)
business_type = Column(String(100), default="bakery") business_type = Column(String(100), default="bakery")
business_model = Column(String(100), default="individual_bakery") # individual_bakery, central_baker_satellite, retail_bakery, hybrid_bakery
# Location info # Location info
address = Column(Text, nullable=False) address = Column(Text, nullable=False)

View File

@@ -17,6 +17,7 @@ class BakeryRegistration(BaseModel):
postal_code: str = Field(..., pattern=r"^\d{5}$") postal_code: str = Field(..., pattern=r"^\d{5}$")
phone: str = Field(..., min_length=9, max_length=20) phone: str = Field(..., min_length=9, max_length=20)
business_type: str = Field(default="bakery") business_type: str = Field(default="bakery")
business_model: Optional[str] = Field(default="individual_bakery")
@validator('phone') @validator('phone')
def validate_spanish_phone(cls, v): def validate_spanish_phone(cls, v):
@@ -41,6 +42,15 @@ class BakeryRegistration(BaseModel):
if v not in valid_types: if v not in valid_types:
raise ValueError(f'Business type must be one of: {valid_types}') raise ValueError(f'Business type must be one of: {valid_types}')
return v return v
@validator('business_model')
def validate_business_model(cls, v):
if v is None:
return v
valid_models = ['individual_bakery', 'central_baker_satellite', 'retail_bakery', 'hybrid_bakery']
if v not in valid_models:
raise ValueError(f'Business model must be one of: {valid_models}')
return v
class TenantResponse(BaseModel): class TenantResponse(BaseModel):
"""Tenant response schema - FIXED VERSION""" """Tenant response schema - FIXED VERSION"""
@@ -48,6 +58,7 @@ class TenantResponse(BaseModel):
name: str name: str
subdomain: Optional[str] subdomain: Optional[str]
business_type: str business_type: str
business_model: Optional[str]
address: str address: str
city: str city: str
postal_code: str postal_code: str
@@ -101,6 +112,7 @@ class TenantUpdate(BaseModel):
address: Optional[str] = Field(None, min_length=10, max_length=500) address: Optional[str] = Field(None, min_length=10, max_length=500)
phone: Optional[str] = None phone: Optional[str] = None
business_type: Optional[str] = None business_type: Optional[str] = None
business_model: Optional[str] = None
class TenantListResponse(BaseModel): class TenantListResponse(BaseModel):
"""Response schema for listing tenants""" """Response schema for listing tenants"""

View File

@@ -121,6 +121,7 @@ class BaseServiceSettings(BaseSettings):
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")
EXTERNAL_SERVICE_URL: str = os.getenv("EXTERNAL_SERVICE_URL", "http://external-service:8000") EXTERNAL_SERVICE_URL: str = os.getenv("EXTERNAL_SERVICE_URL", "http://external-service:8000")
TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000") TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000")
INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000")
NOTIFICATION_SERVICE_URL: str = os.getenv("NOTIFICATION_SERVICE_URL", "http://notification-service:8000") NOTIFICATION_SERVICE_URL: str = os.getenv("NOTIFICATION_SERVICE_URL", "http://notification-service:8000")
NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080") NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080")
@@ -331,6 +332,7 @@ class BaseServiceSettings(BaseSettings):
"sales": self.SALES_SERVICE_URL, "sales": self.SALES_SERVICE_URL,
"external": self.EXTERNAL_SERVICE_URL, "external": self.EXTERNAL_SERVICE_URL,
"tenant": self.TENANT_SERVICE_URL, "tenant": self.TENANT_SERVICE_URL,
"inventory": self.INVENTORY_SERVICE_URL,
"notification": self.NOTIFICATION_SERVICE_URL, "notification": self.NOTIFICATION_SERVICE_URL,
} }

162
test_onboarding_debug.py Normal file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Test script to debug onboarding inventory creation step by step
"""
import asyncio
import json
import httpx
import sys
from uuid import uuid4
# Test configuration
GATEWAY_URL = "http://localhost:8000"
TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
# Test token (you'll need to replace this with a real token)
TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzOTUyYTEwOC1lNWFmLTRlMjktOTJkOC0xMjc0MTBiOWJiYmEiLCJ1c2VyX2lkIjoiMzk1MmExMDgtZTVhZi00ZTI5LTkyZDgtMTI3NDEwYjliYmJhIiwiZW1haWwiOiJkZnNmc2RAdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU1MTY3NTk4LCJpYXQiOjE3NTUxNjU3OTgsImlzcyI6ImJha2VyeS1hdXRoIiwiZnVsbF9uYW1lIjoiZGZzZGZzZGYiLCJpc192ZXJpZmllZCI6ZmFsc2UsImlzX2FjdGl2ZSI6dHJ1ZSwicm9sZSI6InVzZXIifQ.hYyRqqqZ-Ud-uzn42l_ic-QjP-NWYvT8RmwmU12uaQU"
async def test_onboarding_flow():
"""Test the complete onboarding inventory creation flow"""
print("🧪 Testing Onboarding Inventory Creation Flow")
print("=" * 60)
headers = {
"Authorization": f"Bearer {TEST_TOKEN}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=30.0) as client:
# Step 1: Test direct ingredient creation via inventory service
print("\n1⃣ Testing Direct Ingredient Creation via Inventory Service")
print("-" * 50)
ingredient_data = {
"name": "Test Flour",
"description": "Test ingredient for debugging",
"category": "flour", # Use valid enum value
"unit_of_measure": "kg", # Use correct field name
"brand": "Test Supplier",
"is_active": True
}
ingredient_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/ingredients"
print(f"URL: {ingredient_url}")
print(f"Data: {json.dumps(ingredient_data, indent=2)}")
try:
response = await client.post(ingredient_url, json=ingredient_data, headers=headers)
print(f"Status: {response.status_code}")
print(f"Response: {response.text}")
if response.status_code == 201:
print("✅ Direct ingredient creation SUCCESS!")
created_ingredient = response.json()
ingredient_id = created_ingredient.get('id')
print(f"Created ingredient ID: {ingredient_id}")
else:
print("❌ Direct ingredient creation FAILED!")
if response.status_code == 401:
print("❌ Authentication failed - token might be expired")
return
except Exception as e:
print(f"❌ Direct ingredient creation ERROR: {e}")
# Step 2: Test onboarding inventory creation endpoint
print("\n2⃣ Testing Onboarding Inventory Creation Endpoint")
print("-" * 50)
# Create test suggestions like the frontend sends
suggestions = [
{
"suggestion_id": str(uuid4()),
"approved": True,
"modifications": {},
"original_name": "Pan",
"suggested_name": "Pan",
"product_type": "finished_product",
"category": "other_products",
"unit_of_measure": "units",
"confidence_score": 0.9,
"estimated_shelf_life_days": None,
"requires_refrigeration": False,
"requires_freezing": False,
"is_seasonal": False,
"suggested_supplier": None,
"notes": "Test bread product"
},
{
"suggestion_id": str(uuid4()),
"approved": True,
"modifications": {},
"original_name": "Test Croissant",
"suggested_name": "Test Croissant",
"product_type": "finished_product",
"category": "pastries",
"unit_of_measure": "units",
"confidence_score": 0.8,
"estimated_shelf_life_days": 2,
"requires_refrigeration": False,
"requires_freezing": False,
"is_seasonal": False,
"suggested_supplier": "Test Bakery",
"notes": "Test pastry product"
}
]
onboarding_data = {"suggestions": suggestions}
onboarding_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/onboarding/create-inventory"
print(f"URL: {onboarding_url}")
print(f"Suggestions count: {len(suggestions)}")
try:
response = await client.post(onboarding_url, json=onboarding_data, headers=headers)
print(f"Status: {response.status_code}")
print(f"Response: {response.text}")
if response.status_code == 200:
result = response.json()
print(f"✅ Onboarding inventory creation completed!")
print(f"Created items: {len(result.get('created_items', []))}")
print(f"Failed items: {len(result.get('failed_items', []))}")
print(f"Success rate: {result.get('success_rate', 0)}")
if result.get('failed_items'):
print("\n❌ Failed items details:")
for item in result['failed_items']:
print(f" - {item.get('suggestion_id')}: {item.get('error')}")
else:
print("❌ Onboarding inventory creation FAILED!")
except Exception as e:
print(f"❌ Onboarding inventory creation ERROR: {e}")
# Step 3: Test service health
print("\n3⃣ Testing Service Health")
print("-" * 50)
services = [
("Gateway", f"{GATEWAY_URL}/health"),
("Inventory Service", "http://localhost:8008/health"),
("Sales Service", "http://localhost:8004/health")
]
for service_name, health_url in services:
try:
response = await client.get(health_url)
status = "✅ Healthy" if response.status_code == 200 else f"❌ Unhealthy ({response.status_code})"
print(f"{service_name}: {status}")
except Exception as e:
print(f"{service_name}: ❌ Error - {e}")
if __name__ == "__main__":
print("Starting onboarding flow debug test...")
print("Make sure your services are running with docker-compose!")
print()
asyncio.run(test_onboarding_flow())

153
test_sales_import_fix.py Normal file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
Test script to verify the sales import fix for inventory_product_id issue
"""
import asyncio
import json
import httpx
import csv
import io
from uuid import uuid4
# Test configuration
GATEWAY_URL = "http://localhost:8000"
TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
# Test token
TEST_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzOTUyYTEwOC1lNWFmLTRlMjktOTJkOC0xMjc0MTBiOWJiYmEiLCJ1c2VyX2lkIjoiMzk1MmExMDgtZTVhZi00ZTI5LTkyZDgtMTI3NDEwYjliYmJhIiwiZW1haWwiOiJkZnNmc2RAdGVzdC5jb20iLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzU1MTY3NTk4LCJpYXQiOjE3NTUxNjU3OTgsImlzcyI6ImJha2VyeS1hdXRoIiwiZnVsbF9uYW1lIjoiZGZzZGZzZGYiLCJpc192ZXJpZmllZCI6ZmFsc2UsImlzX2FjdGl2ZSI6dHJ1ZSwicm9sZSI6InVzZXIifQ.hYyRqqqZ-Ud-uzn42l_ic-QjP-NWYvT8RmwmU12uaQU"
async def test_sales_import():
"""Test the sales import functionality with the inventory_product_id fix"""
print("🧪 Testing Sales Import Fix for inventory_product_id")
print("=" * 60)
headers = {
"Authorization": f"Bearer {TEST_TOKEN}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient(timeout=60.0) as client:
# Step 1: Create test CSV data
print("\n1⃣ Creating Test CSV Data")
print("-" * 50)
csv_data = [
["date", "product_name", "quantity_sold", "revenue", "location_id"],
["2024-01-15", "Test Bread", "10", "25.50", "store-1"],
["2024-01-15", "Test Croissant", "5", "15.00", "store-1"],
["2024-01-15", "Test Muffin", "8", "20.00", "store-2"]
]
# Convert to CSV string
csv_string = io.StringIO()
writer = csv.writer(csv_string)
for row in csv_data:
writer.writerow(row)
csv_content = csv_string.getvalue()
print(f"CSV Content:")
print(csv_content)
# Step 2: Test the import endpoint
print("\n2⃣ Testing Sales Data Import")
print("-" * 50)
import_data = {
"csv_data": csv_content,
"import_type": "enhanced",
"overwrite_existing": False
}
import_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/sales/import/csv"
print(f"URL: {import_url}")
try:
response = await client.post(import_url, json=import_data, headers=headers)
print(f"Status: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Sales import completed!")
print(f"Records processed: {result.get('records_processed', 0)}")
print(f"Records created: {result.get('records_created', 0)}")
print(f"Records failed: {result.get('records_failed', 0)}")
print(f"Success: {result.get('success', False)}")
if result.get('errors'):
print(f"\n❌ Errors ({len(result['errors'])}):")
for error in result['errors'][:5]: # Show first 5 errors
print(f" - {error}")
if result.get('warnings'):
print(f"\n⚠️ Warnings ({len(result['warnings'])}):")
for warning in result['warnings'][:3]: # Show first 3 warnings
print(f" - {warning}")
else:
print("❌ Sales import FAILED!")
print(f"Response: {response.text}")
except Exception as e:
print(f"❌ Sales import ERROR: {e}")
# Step 3: Check if inventory items were created
print("\n3⃣ Checking Inventory Items Creation")
print("-" * 50)
try:
ingredients_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/ingredients"
response = await client.get(ingredients_url, headers=headers)
if response.status_code == 200:
ingredients = response.json()
print(f"✅ Found {len(ingredients)} inventory items")
test_products = ["Test Bread", "Test Croissant", "Test Muffin"]
for product in test_products:
found = any(ingredient.get('name') == product for ingredient in ingredients)
status = "" if found else ""
print(f" {status} {product}")
else:
print(f"❌ Failed to fetch inventory items: {response.status_code}")
except Exception as e:
print(f"❌ Error checking inventory items: {e}")
# Step 4: Check sales records
print("\n4⃣ Checking Sales Records")
print("-" * 50)
try:
sales_url = f"{GATEWAY_URL}/api/v1/tenants/{TENANT_ID}/sales"
response = await client.get(sales_url, headers=headers)
if response.status_code == 200:
sales_data = response.json()
sales_count = len(sales_data) if isinstance(sales_data, list) else sales_data.get('total', 0)
print(f"✅ Found {sales_count} sales records")
# Show recent records
records = sales_data if isinstance(sales_data, list) else sales_data.get('records', [])
for i, record in enumerate(records[:3]): # Show first 3 records
inventory_id = record.get('inventory_product_id')
date = record.get('date', 'Unknown')
quantity = record.get('quantity_sold', 0)
revenue = record.get('revenue', 0)
print(f" Record {i+1}: Date={date}, Qty={quantity}, Revenue=${revenue}, InventoryID={inventory_id}")
else:
print(f"❌ Failed to fetch sales records: {response.status_code}")
except Exception as e:
print(f"❌ Error checking sales records: {e}")
if __name__ == "__main__":
print("Testing sales import fix...")
print("Make sure your services are running with docker-compose!")
print()
asyncio.run(test_sales_import())