Fix new services implementation 2
This commit is contained in:
@@ -392,6 +392,57 @@ private buildURL(endpoint: string): string {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -88,7 +88,7 @@ interface UseInventoryItemReturn {
|
||||
// ========== MAIN INVENTORY HOOK ==========
|
||||
|
||||
export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||
const tenantId = useTenantId();
|
||||
const { tenantId } = useTenantId();
|
||||
|
||||
// State
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
@@ -373,7 +373,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||
// ========== DASHBOARD HOOK ==========
|
||||
|
||||
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
||||
const tenantId = useTenantId();
|
||||
const { tenantId } = useTenantId();
|
||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
||||
const [alerts, setAlerts] = useState<StockAlert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -419,7 +419,7 @@ export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
||||
// ========== SINGLE ITEM HOOK ==========
|
||||
|
||||
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
|
||||
const tenantId = useTenantId();
|
||||
const { tenantId } = useTenantId();
|
||||
const [item, setItem] = useState<InventoryItem | null>(null);
|
||||
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
|
||||
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
|
||||
|
||||
@@ -22,6 +22,23 @@ import {
|
||||
} from '../services/suppliers.service';
|
||||
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();
|
||||
|
||||
// ============================================================================
|
||||
@@ -196,13 +213,13 @@ export function useSuppliers(): UseSuppliers {
|
||||
|
||||
// Create supplier
|
||||
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 {
|
||||
setIsCreating(true);
|
||||
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
|
||||
await loadSuppliers(currentParams);
|
||||
@@ -217,17 +234,17 @@ export function useSuppliers(): UseSuppliers {
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [user?.tenant_id, user?.user_id, loadSuppliers, loadStatistics, currentParams]);
|
||||
}, [user?.tenant_id, user?.id, loadSuppliers, loadStatistics, currentParams]);
|
||||
|
||||
// Update supplier
|
||||
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 {
|
||||
setIsUpdating(true);
|
||||
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
|
||||
if (supplier?.id === supplierId) {
|
||||
@@ -246,7 +263,7 @@ export function useSuppliers(): UseSuppliers {
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [user?.tenant_id, user?.user_id, supplier?.id, loadSuppliers, currentParams]);
|
||||
}, [user?.tenant_id, user?.id, supplier?.id, loadSuppliers, currentParams]);
|
||||
|
||||
// Delete supplier
|
||||
const deleteSupplier = useCallback(async (supplierId: string): Promise<boolean> => {
|
||||
@@ -277,12 +294,12 @@ export function useSuppliers(): UseSuppliers {
|
||||
|
||||
// Approve/reject supplier
|
||||
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 {
|
||||
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
|
||||
if (supplier?.id === supplierId) {
|
||||
@@ -300,7 +317,7 @@ export function useSuppliers(): UseSuppliers {
|
||||
setError(errorMessage);
|
||||
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
|
||||
const clearError = useCallback(() => {
|
||||
@@ -504,13 +521,13 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
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 {
|
||||
setIsCreating(true);
|
||||
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
|
||||
await loadPurchaseOrders(currentParams);
|
||||
@@ -525,15 +542,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
||||
} finally {
|
||||
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> => {
|
||||
if (!user?.tenant_id || !user?.user_id) return null;
|
||||
if (!user?.tenant_id || !user?.id) return null;
|
||||
|
||||
try {
|
||||
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) {
|
||||
setPurchaseOrder(updatedOrder);
|
||||
@@ -548,15 +565,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
||||
setError(errorMessage);
|
||||
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> => {
|
||||
if (!user?.tenant_id || !user?.user_id) return null;
|
||||
if (!user?.tenant_id || !user?.id) return null;
|
||||
|
||||
try {
|
||||
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) {
|
||||
setPurchaseOrder(updatedOrder);
|
||||
@@ -572,15 +589,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
||||
setError(errorMessage);
|
||||
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> => {
|
||||
if (!user?.tenant_id || !user?.user_id) return null;
|
||||
if (!user?.tenant_id || !user?.id) return null;
|
||||
|
||||
try {
|
||||
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) {
|
||||
setPurchaseOrder(updatedOrder);
|
||||
@@ -595,15 +612,15 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
||||
setError(errorMessage);
|
||||
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> => {
|
||||
if (!user?.tenant_id || !user?.user_id) return null;
|
||||
if (!user?.tenant_id || !user?.id) return null;
|
||||
|
||||
try {
|
||||
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) {
|
||||
setPurchaseOrder(updatedOrder);
|
||||
@@ -618,7 +635,7 @@ export function usePurchaseOrders(): UsePurchaseOrders {
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
||||
}, [user?.tenant_id, user?.id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -804,12 +821,12 @@ export function useDeliveries(): UseDeliveries {
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
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 {
|
||||
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) {
|
||||
setDelivery(updatedDelivery);
|
||||
@@ -824,15 +841,15 @@ export function useDeliveries(): UseDeliveries {
|
||||
setError(errorMessage);
|
||||
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> => {
|
||||
if (!user?.tenant_id || !user?.user_id) return null;
|
||||
if (!user?.tenant_id || !user?.id) return null;
|
||||
|
||||
try {
|
||||
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) {
|
||||
setDelivery(updatedDelivery);
|
||||
@@ -847,7 +864,7 @@ export function useDeliveries(): UseDeliveries {
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, [user?.tenant_id, user?.user_id, delivery?.id, loadDeliveries, currentParams]);
|
||||
}, [user?.tenant_id, user?.id, delivery?.id, loadDeliveries, currentParams]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface InventoryItem {
|
||||
supplier?: string;
|
||||
notes?: string;
|
||||
barcode?: string;
|
||||
sku?: string;
|
||||
cost_per_unit?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
|
||||
@@ -211,7 +211,20 @@ export class OnboardingService {
|
||||
suggestions: suggestions.map(s => ({
|
||||
suggestion_id: s.suggestion_id,
|
||||
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
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -367,14 +367,14 @@ export class RecipesService {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> {
|
||||
const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> {
|
||||
@@ -384,7 +384,7 @@ export class RecipesService {
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> {
|
||||
@@ -394,7 +394,7 @@ export class RecipesService {
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -423,7 +423,7 @@ export class RecipesService {
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> {
|
||||
@@ -431,21 +431,21 @@ export class RecipesService {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params: { batch_multiplier: batchMultiplier }
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> {
|
||||
const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getRecipeCategories(tenantId: string): Promise<string[]> {
|
||||
const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response.data.categories;
|
||||
return response.categories;
|
||||
}
|
||||
|
||||
// Production Management
|
||||
@@ -454,14 +454,14 @@ export class RecipesService {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> {
|
||||
const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> {
|
||||
@@ -471,7 +471,7 @@ export class RecipesService {
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> {
|
||||
@@ -481,7 +481,7 @@ export class RecipesService {
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
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`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async startProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
||||
@@ -519,7 +519,7 @@ export class RecipesService {
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
||||
@@ -538,7 +538,7 @@ export class RecipesService {
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> {
|
||||
@@ -546,6 +546,6 @@ export class RecipesService {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params: { start_date: startDate, end_date: endDate }
|
||||
});
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -361,7 +361,7 @@ export class SuppliersService {
|
||||
`${this.baseUrl}?${searchParams.toString()}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getSupplier(tenantId: string, supplierId: string): Promise<Supplier> {
|
||||
@@ -369,7 +369,7 @@ export class SuppliersService {
|
||||
`${this.baseUrl}/${supplierId}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async createSupplier(tenantId: string, userId: string, data: CreateSupplierRequest): Promise<Supplier> {
|
||||
@@ -378,7 +378,7 @@ export class SuppliersService {
|
||||
data,
|
||||
{ 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> {
|
||||
@@ -387,7 +387,7 @@ export class SuppliersService {
|
||||
data,
|
||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async deleteSupplier(tenantId: string, supplierId: string): Promise<void> {
|
||||
@@ -403,7 +403,7 @@ export class SuppliersService {
|
||||
{ action, notes },
|
||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
// Supplier Analytics & Lists
|
||||
@@ -412,7 +412,7 @@ export class SuppliersService {
|
||||
`${this.baseUrl}/statistics`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getActiveSuppliers(tenantId: string): Promise<SupplierSummary[]> {
|
||||
@@ -420,7 +420,7 @@ export class SuppliersService {
|
||||
`${this.baseUrl}/active`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getTopSuppliers(tenantId: string, limit: number = 10): Promise<SupplierSummary[]> {
|
||||
@@ -428,7 +428,7 @@ export class SuppliersService {
|
||||
`${this.baseUrl}/top?limit=${limit}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getSuppliersByType(tenantId: string, supplierType: string): Promise<SupplierSummary[]> {
|
||||
@@ -436,7 +436,7 @@ export class SuppliersService {
|
||||
`${this.baseUrl}/types/${supplierType}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
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}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
// Purchase Orders
|
||||
@@ -463,7 +463,7 @@ export class SuppliersService {
|
||||
`/api/v1/purchase-orders?${searchParams.toString()}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getPurchaseOrder(tenantId: string, poId: string): Promise<PurchaseOrder> {
|
||||
@@ -471,7 +471,7 @@ export class SuppliersService {
|
||||
`/api/v1/purchase-orders/${poId}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async createPurchaseOrder(tenantId: string, userId: string, data: CreatePurchaseOrderRequest): Promise<PurchaseOrder> {
|
||||
@@ -480,7 +480,7 @@ export class SuppliersService {
|
||||
data,
|
||||
{ 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> {
|
||||
@@ -489,7 +489,7 @@ export class SuppliersService {
|
||||
{ status, notes },
|
||||
{ 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> {
|
||||
@@ -498,7 +498,7 @@ export class SuppliersService {
|
||||
{ action, notes },
|
||||
{ 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> {
|
||||
@@ -507,7 +507,7 @@ export class SuppliersService {
|
||||
{},
|
||||
{ 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> {
|
||||
@@ -516,7 +516,7 @@ export class SuppliersService {
|
||||
{},
|
||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getPurchaseOrderStatistics(tenantId: string): Promise<PurchaseOrderStatistics> {
|
||||
@@ -524,7 +524,7 @@ export class SuppliersService {
|
||||
'/api/v1/purchase-orders/statistics',
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getOrdersRequiringApproval(tenantId: string): Promise<PurchaseOrder[]> {
|
||||
@@ -532,7 +532,7 @@ export class SuppliersService {
|
||||
'/api/v1/purchase-orders/pending-approval',
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getOverdueOrders(tenantId: string): Promise<PurchaseOrder[]> {
|
||||
@@ -540,7 +540,7 @@ export class SuppliersService {
|
||||
'/api/v1/purchase-orders/overdue',
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
// Deliveries
|
||||
@@ -558,7 +558,7 @@ export class SuppliersService {
|
||||
`/api/v1/deliveries?${searchParams.toString()}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getDelivery(tenantId: string, deliveryId: string): Promise<Delivery> {
|
||||
@@ -566,7 +566,7 @@ export class SuppliersService {
|
||||
`/api/v1/deliveries/${deliveryId}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getTodaysDeliveries(tenantId: string): Promise<Delivery[]> {
|
||||
@@ -574,7 +574,7 @@ export class SuppliersService {
|
||||
'/api/v1/deliveries/today',
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async getOverdueDeliveries(tenantId: string): Promise<Delivery[]> {
|
||||
@@ -582,7 +582,7 @@ export class SuppliersService {
|
||||
'/api/v1/deliveries/overdue',
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
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 },
|
||||
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
|
||||
async receiveDelivery(tenantId: string, userId: string, deliveryId: string, receiptData: {
|
||||
@@ -605,7 +605,7 @@ export class SuppliersService {
|
||||
receiptData,
|
||||
{ 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> {
|
||||
@@ -617,6 +617,6 @@ export class SuppliersService {
|
||||
`/api/v1/deliveries/performance-stats?${params.toString()}`,
|
||||
{ headers: { 'X-Tenant-ID': tenantId } }
|
||||
);
|
||||
return response.data;
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -40,3 +40,15 @@ export interface BaseQueryParams {
|
||||
sort?: string;
|
||||
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;
|
||||
}
|
||||
@@ -18,6 +18,15 @@ export interface SalesData {
|
||||
source: string;
|
||||
created_at: string;
|
||||
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 {
|
||||
@@ -53,6 +62,10 @@ export interface SalesDataQuery extends BaseQueryParams {
|
||||
max_quantity?: number;
|
||||
min_revenue?: number;
|
||||
max_revenue?: number;
|
||||
search_term?: string;
|
||||
sales_channel?: string;
|
||||
inventory_product_id?: string;
|
||||
is_validated?: boolean;
|
||||
}
|
||||
|
||||
export interface SalesDataImport {
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface TenantInfo {
|
||||
settings?: TenantSettings;
|
||||
subscription?: TenantSubscription;
|
||||
location?: TenantLocation;
|
||||
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
||||
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
||||
}
|
||||
|
||||
export interface TenantSettings {
|
||||
@@ -62,7 +64,8 @@ export interface TenantSubscription {
|
||||
export interface TenantCreate {
|
||||
name: 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;
|
||||
phone: string;
|
||||
description?: string;
|
||||
|
||||
@@ -120,8 +120,8 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
||||
|
||||
const config = {
|
||||
url: actualTenantId
|
||||
? `ws://localhost:8002/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/${actualTenantId}/training/jobs/${jobId}/live`
|
||||
: `ws://localhost:8000/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
|
||||
reconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
Lightbulb
|
||||
Lightbulb,
|
||||
Building2,
|
||||
Truck
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -236,6 +238,43 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
|
||||
const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => {
|
||||
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: {
|
||||
icon: Factory,
|
||||
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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -43,8 +43,8 @@ const SalesAnalyticsDashboard: React.FC = () => {
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
ingredients: products,
|
||||
loadIngredients: loadProducts,
|
||||
items: products,
|
||||
loadItems: loadProducts,
|
||||
isLoading: inventoryLoading
|
||||
} = useInventory();
|
||||
|
||||
@@ -159,11 +159,14 @@ const SalesAnalyticsDashboard: React.FC = () => {
|
||||
|
||||
// Top products
|
||||
const topProducts = Object.entries(productPerformance)
|
||||
.map(([productId, data]) => ({
|
||||
.map(([productId, data]) => {
|
||||
const perf = data as { revenue: number; units: number; orders: number };
|
||||
return {
|
||||
productId,
|
||||
...data as any,
|
||||
avgPrice: data.revenue / data.units
|
||||
}))
|
||||
...perf,
|
||||
avgPrice: perf.revenue / perf.units
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
.slice(0, 5);
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ const SalesDashboardWidget: React.FC<SalesDashboardWidgetProps> = ({
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">Ventas de Hoy</h3>
|
||||
{onViewAll && (
|
||||
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
||||
<Button variant="outline" size="sm" onClick={onViewAll}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Package,
|
||||
ShoppingCart,
|
||||
MapPin,
|
||||
Grid3X3,
|
||||
Grid,
|
||||
List,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
@@ -51,8 +51,8 @@ const SalesManagementPage: React.FC = () => {
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
ingredients: products,
|
||||
loadIngredients: loadProducts,
|
||||
items: products,
|
||||
loadItems: loadProducts,
|
||||
isLoading: inventoryLoading
|
||||
} = useInventory();
|
||||
|
||||
@@ -89,7 +89,9 @@ const SalesManagementPage: React.FC = () => {
|
||||
const loadSalesData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
const query: SalesDataQuery = {};
|
||||
const query: SalesDataQuery = {
|
||||
tenant_id: user.tenant_id
|
||||
};
|
||||
|
||||
if (filters.search) {
|
||||
query.search_term = filters.search;
|
||||
@@ -155,7 +157,9 @@ const SalesManagementPage: React.FC = () => {
|
||||
const handleExport = async () => {
|
||||
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_to) query.end_date = filters.date_to;
|
||||
if (filters.channel) query.sales_channel = filters.channel;
|
||||
@@ -386,7 +390,7 @@ const SalesManagementPage: React.FC = () => {
|
||||
onClick={() => setViewMode('grid')}
|
||||
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
|
||||
onClick={() => setViewMode('list')}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Clock,
|
||||
Star,
|
||||
ArrowRight,
|
||||
LightBulb,
|
||||
Lightbulb,
|
||||
Calendar,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
@@ -53,10 +53,10 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
predictions,
|
||||
loadPredictions,
|
||||
performance,
|
||||
loadPerformance,
|
||||
forecasts,
|
||||
getForecasts,
|
||||
quickForecasts,
|
||||
getQuickForecasts,
|
||||
isLoading: forecastLoading
|
||||
} = useForecast();
|
||||
|
||||
@@ -87,8 +87,8 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
end_date: endDate,
|
||||
limit: 1000
|
||||
}),
|
||||
loadPredictions(),
|
||||
loadPerformance()
|
||||
getForecasts(user.tenant_id),
|
||||
getQuickForecasts(user.tenant_id)
|
||||
]);
|
||||
|
||||
setSalesAnalytics(analytics);
|
||||
@@ -200,9 +200,9 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
}
|
||||
|
||||
// Forecasting insights
|
||||
if (predictions.length > 0) {
|
||||
const todayPrediction = predictions.find(p => {
|
||||
const predDate = new Date(p.date).toDateString();
|
||||
if (forecasts.length > 0) {
|
||||
const todayPrediction = forecasts.find(p => {
|
||||
const predDate = new Date(p.forecast_date).toDateString();
|
||||
const today = new Date().toDateString();
|
||||
return predDate === today;
|
||||
});
|
||||
@@ -213,8 +213,8 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
type: 'forecast',
|
||||
title: 'Predicción para hoy',
|
||||
description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${
|
||||
todayPrediction.confidence === 'high' ? 'alta' :
|
||||
todayPrediction.confidence === 'medium' ? 'media' : 'baja'
|
||||
(todayPrediction.confidence_level || 0) > 0.8 ? 'alta' :
|
||||
(todayPrediction.confidence_level || 0) > 0.6 ? 'media' : 'baja'
|
||||
} confianza.`,
|
||||
value: `${todayPrediction.predicted_demand} unidades`,
|
||||
priority: 'high',
|
||||
@@ -227,8 +227,9 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
}
|
||||
|
||||
// Performance vs forecast insight
|
||||
if (performance) {
|
||||
const accuracy = performance.accuracy || 0;
|
||||
if (quickForecasts.length > 0) {
|
||||
const latestForecast = quickForecasts[0];
|
||||
const accuracy = latestForecast.confidence_score || 0;
|
||||
if (accuracy > 85) {
|
||||
insights.push({
|
||||
id: 'forecast_accuracy',
|
||||
@@ -288,7 +289,7 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
// Sort by priority
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
|
||||
}, [salesAnalytics, salesData, predictions, performance, onActionClick]);
|
||||
}, [salesAnalytics, salesData, forecasts, quickForecasts, onActionClick]);
|
||||
|
||||
// Get insight icon
|
||||
const getInsightIcon = (type: PerformanceInsight['type']) => {
|
||||
@@ -301,7 +302,7 @@ const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
return Brain;
|
||||
case 'info':
|
||||
default:
|
||||
return LightBulb;
|
||||
return Lightbulb;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
CheckCircle,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Grid3X3,
|
||||
Grid,
|
||||
List,
|
||||
Download,
|
||||
MapPin,
|
||||
@@ -486,7 +486,7 @@ const DeliveryTrackingPage: React.FC = () => {
|
||||
onClick={() => setViewMode('grid')}
|
||||
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
|
||||
onClick={() => setViewMode('list')}
|
||||
|
||||
@@ -100,7 +100,7 @@ const PurchaseOrderForm: React.FC<PurchaseOrderFormProps> = ({
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'delivery' | 'items' | 'financial'>('basic');
|
||||
|
||||
const { activeSuppliers, loadActiveSuppliers } = useSuppliers();
|
||||
const { ingredients, loadInventoryItems } = useInventory();
|
||||
const { items: ingredients, loadItems: loadInventoryItems } = useInventory();
|
||||
|
||||
// Initialize form data when order changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
AlertCircle,
|
||||
Package,
|
||||
DollarSign,
|
||||
Grid3X3,
|
||||
Grid,
|
||||
List
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -446,7 +446,7 @@ const PurchaseOrderManagementPage: React.FC = () => {
|
||||
onClick={() => setViewMode('grid')}
|
||||
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
|
||||
onClick={() => setViewMode('list')}
|
||||
@@ -599,7 +599,7 @@ const PurchaseOrderManagementPage: React.FC = () => {
|
||||
isOpen={showPurchaseOrderForm}
|
||||
isCreating={isCreating}
|
||||
onSubmit={selectedOrder ?
|
||||
(data) => {
|
||||
async (data) => {
|
||||
// Handle update logic here if needed
|
||||
setShowPurchaseOrderForm(false);
|
||||
setSelectedOrder(null);
|
||||
|
||||
@@ -43,6 +43,8 @@ interface SupplierCostData extends SupplierSummary {
|
||||
market_share_percentage: number;
|
||||
cost_trend: 'increasing' | 'decreasing' | 'stable';
|
||||
cost_efficiency_score: number;
|
||||
total_amount: number;
|
||||
quality_rating: number;
|
||||
}
|
||||
|
||||
const SupplierCostAnalysis: React.FC = () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
AlertCircle,
|
||||
Package,
|
||||
DollarSign,
|
||||
Grid3X3,
|
||||
Grid,
|
||||
List
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -422,7 +422,7 @@ const SupplierManagementPage: React.FC = () => {
|
||||
onClick={() => setViewMode('grid')}
|
||||
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
|
||||
onClick={() => setViewMode('list')}
|
||||
|
||||
@@ -42,6 +42,10 @@ interface SupplierPerformance extends SupplierSummary {
|
||||
cost_efficiency: number;
|
||||
response_time: number;
|
||||
quality_consistency: number;
|
||||
total_orders: number;
|
||||
total_amount: number;
|
||||
quality_rating: number;
|
||||
delivery_rating: number;
|
||||
}
|
||||
|
||||
const SupplierPerformanceReport: React.FC = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
|
||||
@@ -25,7 +25,7 @@ interface OnboardingPageProps {
|
||||
interface BakeryData {
|
||||
name: 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 };
|
||||
products: string[];
|
||||
hasHistoricalData: boolean;
|
||||
@@ -51,7 +51,6 @@ const MADRID_PRODUCTS = [
|
||||
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [useSmartImport, setUseSmartImport] = useState(true); // New state for smart import
|
||||
const manualNavigation = useRef(false);
|
||||
|
||||
// Enhanced onboarding with progress tracking
|
||||
@@ -64,7 +63,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
const [bakeryData, setBakeryData] = useState<BakeryData>({
|
||||
name: '',
|
||||
address: '',
|
||||
businessType: 'individual',
|
||||
// businessType will be auto-detected during smart data import
|
||||
products: MADRID_PRODUCTS, // Automatically assign all products
|
||||
hasHistoricalData: false
|
||||
});
|
||||
@@ -95,14 +94,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
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)
|
||||
useEffect(() => {
|
||||
@@ -292,8 +283,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
try {
|
||||
if (currentStep === 1) {
|
||||
await createBakeryAndTenant();
|
||||
} else if (currentStep === 2) {
|
||||
await uploadAndValidateSalesData();
|
||||
// Smart import step handles its own processing
|
||||
}
|
||||
} catch (error) {
|
||||
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 = {
|
||||
name: bakeryData.name,
|
||||
address: bakeryData.address,
|
||||
business_type: "individual",
|
||||
business_type: 'bakery', // Default value - will be automatically updated after AI analyzes sales data
|
||||
postal_code: "28010",
|
||||
phone: "+34655334455",
|
||||
coordinates: bakeryData.coordinates,
|
||||
@@ -337,7 +327,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
await completeStep('bakery_registered', {
|
||||
bakery_name: bakeryData.name,
|
||||
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
|
||||
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
|
||||
const markStepCompleted = async () => {
|
||||
@@ -450,7 +367,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
if (currentStep === 1) {
|
||||
stepData.bakery_name = bakeryData.name;
|
||||
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;
|
||||
} else if (currentStep === 2) {
|
||||
stepData.file_name = bakeryData.csvFile?.name;
|
||||
@@ -479,39 +397,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
}
|
||||
return true;
|
||||
case 2:
|
||||
// Skip validation if using smart import (it 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;
|
||||
}
|
||||
|
||||
// Smart import handles its own validation
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
@@ -672,37 +558,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Tipo de negocio
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'individual' }))}
|
||||
className={`p-4 border rounded-xl text-left transition-all ${
|
||||
bakeryData.businessType === 'individual'
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<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 className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Brain className="w-5 h-5 text-blue-500 mt-0.5" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-blue-900">
|
||||
🤖 Detección automática del tipo de negocio
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Nuestro sistema de IA analizará automáticamente tus datos de ventas para identificar
|
||||
si eres una panadería, cafetería, pastelería o restaurante. No necesitas seleccionar nada manualmente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -724,30 +593,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
);
|
||||
}
|
||||
|
||||
// Use Smart Import by default, with option to switch to traditional
|
||||
if (useSmartImport) {
|
||||
// Smart Import only
|
||||
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}
|
||||
@@ -768,318 +616,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
startTraining();
|
||||
});
|
||||
}}
|
||||
onBack={() => setUseSmartImport(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Traditional import fallback
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with import mode toggle */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Datos Históricos (Modo Tradicional)
|
||||
</h3>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Sube tus datos y configura tu inventario manualmente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setUseSmartImport(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"
|
||||
>
|
||||
<Brain className="w-4 h-4" />
|
||||
<span>Activar IA</span>
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
@@ -1369,7 +908,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
</main>
|
||||
|
||||
{/* 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
|
||||
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
|
||||
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-xs text-gray-400 mt-1">
|
||||
{currentStep === 1 && "Configura tu panadería"}
|
||||
{currentStep === 2 && "Sube datos de ventas"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Next Button with contextual text */}
|
||||
<button
|
||||
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"
|
||||
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 ? (
|
||||
<>
|
||||
@@ -1412,11 +950,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
) : (
|
||||
<>
|
||||
{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" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ Handles routing, authentication, rate limiting, and cross-cutting concerns
|
||||
|
||||
import asyncio
|
||||
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.responses import JSONResponse
|
||||
import httpx
|
||||
@@ -17,7 +17,7 @@ from app.core.service_discovery import ServiceDiscovery
|
||||
from app.middleware.auth import AuthMiddleware
|
||||
from app.middleware.logging import LoggingMiddleware
|
||||
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.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(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
|
||||
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")
|
||||
async def startup_event():
|
||||
@@ -117,6 +116,92 @@ async def metrics():
|
||||
"""Metrics endpoint for monitoring"""
|
||||
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__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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("/")
|
||||
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
|
||||
# ================================================================
|
||||
@@ -162,6 +182,10 @@ async def _proxy_to_notification_service(request: Request, target_path: str):
|
||||
"""Proxy request to notification service"""
|
||||
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):
|
||||
"""Generic proxy function with enhanced error handling"""
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ AI-powered product classification for onboarding automation
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from typing import List, Dict, Any
|
||||
from uuid import UUID
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID, uuid4
|
||||
from pydantic import BaseModel, Field
|
||||
import structlog
|
||||
|
||||
@@ -38,12 +38,12 @@ class ProductSuggestionResponse(BaseModel):
|
||||
category: str
|
||||
unit_of_measure: str
|
||||
confidence_score: float
|
||||
estimated_shelf_life_days: int = None
|
||||
estimated_shelf_life_days: Optional[int] = None
|
||||
requires_refrigeration: bool = False
|
||||
requires_freezing: bool = False
|
||||
is_seasonal: bool = False
|
||||
suggested_supplier: str = None
|
||||
notes: str = None
|
||||
suggested_supplier: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class BusinessModelAnalysisResponse(BaseModel):
|
||||
@@ -87,7 +87,7 @@ async def classify_single_product(
|
||||
|
||||
# Convert to response format
|
||||
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,
|
||||
suggested_name=suggestion.suggested_name,
|
||||
product_type=suggestion.product_type.value,
|
||||
@@ -144,7 +144,7 @@ async def classify_products_batch(
|
||||
suggestion_responses = []
|
||||
for suggestion in suggestions:
|
||||
suggestion_responses.append(ProductSuggestionResponse(
|
||||
suggestion_id=str(UUID.uuid4()),
|
||||
suggestion_id=str(uuid4()),
|
||||
original_name=suggestion.original_name,
|
||||
suggested_name=suggestion.suggested_name,
|
||||
product_type=suggestion.product_type.value,
|
||||
@@ -159,39 +159,58 @@ async def classify_products_batch(
|
||||
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')
|
||||
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)
|
||||
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:
|
||||
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:
|
||||
model = 'retail'
|
||||
model = 'retail_bakery' # Sells finished products from suppliers
|
||||
else:
|
||||
model = 'hybrid'
|
||||
model = 'hybrid_bakery' # Mixed model
|
||||
|
||||
# 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 = {
|
||||
'production': [
|
||||
'Focus on ingredient inventory management',
|
||||
'Set up recipe cost calculation',
|
||||
'Configure supplier relationships',
|
||||
'Enable production planning features'
|
||||
'individual_bakery': [
|
||||
'Set up raw ingredient inventory management',
|
||||
'Configure recipe cost calculation and production planning',
|
||||
'Enable supplier relationships for flour, yeast, sugar, etc.',
|
||||
'Set up full production workflow with proofing and baking schedules',
|
||||
'Enable waste tracking for overproduction'
|
||||
],
|
||||
'retail': [
|
||||
'Configure central baker relationships',
|
||||
'Set up delivery schedule tracking',
|
||||
'Enable finished product freshness monitoring',
|
||||
'Focus on sales forecasting'
|
||||
'central_baker_satellite': [
|
||||
'Configure central baker delivery schedules',
|
||||
'Set up semi-finished product inventory (frozen dough, par-baked items)',
|
||||
'Enable finish-baking workflow and timing optimization',
|
||||
'Track freshness and shelf-life for received products',
|
||||
'Focus on customer demand forecasting for final products'
|
||||
],
|
||||
'hybrid': [
|
||||
'Configure both ingredient and finished product management',
|
||||
'Set up flexible inventory categories',
|
||||
'Enable both production and retail features'
|
||||
'retail_bakery': [
|
||||
'Set up finished product supplier relationships',
|
||||
'Configure delivery schedule tracking',
|
||||
'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'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ API endpoints for ingredient management
|
||||
|
||||
from typing import List, Optional
|
||||
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 app.core.database import get_db
|
||||
@@ -17,10 +17,9 @@ from app.schemas.inventory import (
|
||||
InventoryFilter,
|
||||
PaginatedResponse
|
||||
)
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.tenant_access import verify_tenant_access_dep
|
||||
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||
|
||||
router = APIRouter(prefix="/ingredients", tags=["ingredients"])
|
||||
router = APIRouter(tags=["ingredients"])
|
||||
|
||||
# Helper function to extract user ID from user object
|
||||
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)
|
||||
|
||||
|
||||
@router.post("/", response_model=IngredientResponse)
|
||||
@router.post("/tenants/{tenant_id}/ingredients", response_model=IngredientResponse)
|
||||
async def create_ingredient(
|
||||
ingredient_data: IngredientCreate,
|
||||
tenant_id: UUID = Depends(verify_tenant_access_dep),
|
||||
user_id: UUID = Depends(get_current_user_id),
|
||||
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)
|
||||
):
|
||||
"""Create a new ingredient"""
|
||||
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()
|
||||
ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id)
|
||||
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(
|
||||
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)
|
||||
):
|
||||
"""Get ingredient by ID"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
service = InventoryService()
|
||||
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(
|
||||
ingredient_id: UUID,
|
||||
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)
|
||||
):
|
||||
"""Update ingredient"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
service = InventoryService()
|
||||
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(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
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"),
|
||||
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"),
|
||||
needs_reorder: Optional[bool] = Query(None, description="Filter by reorder needed"),
|
||||
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)
|
||||
):
|
||||
"""List ingredients with filtering"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
service = InventoryService()
|
||||
|
||||
# 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(
|
||||
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)
|
||||
):
|
||||
"""Soft delete ingredient (mark as inactive)"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
service = InventoryService()
|
||||
ingredient = await service.update_ingredient(
|
||||
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(
|
||||
ingredient_id: UUID,
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
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)
|
||||
):
|
||||
"""Get stock entries for an ingredient"""
|
||||
try:
|
||||
# Verify tenant access
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this tenant")
|
||||
|
||||
service = InventoryService()
|
||||
stock_entries = await service.get_stock_by_ingredient(
|
||||
ingredient_id, tenant_id, include_unavailable
|
||||
|
||||
@@ -160,6 +160,14 @@ class Ingredient(Base):
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""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 {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
@@ -167,6 +175,7 @@ class Ingredient(Base):
|
||||
'sku': self.sku,
|
||||
'barcode': self.barcode,
|
||||
'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,
|
||||
'product_category': self.product_category.value if self.product_category else None,
|
||||
'subcategory': self.subcategory,
|
||||
|
||||
@@ -26,17 +26,53 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
|
||||
async def create_ingredient(self, ingredient_data: IngredientCreate, tenant_id: UUID) -> Ingredient:
|
||||
"""Create a new ingredient"""
|
||||
try:
|
||||
# Prepare data
|
||||
# Prepare data and map schema fields to model fields
|
||||
create_data = ingredient_data.model_dump()
|
||||
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
|
||||
record = await self.create(create_data)
|
||||
logger.info(
|
||||
"Created ingredient",
|
||||
ingredient_id=record.id,
|
||||
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
|
||||
)
|
||||
return record
|
||||
|
||||
@@ -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):
|
||||
"""Request to approve/modify inventory suggestions"""
|
||||
suggestions: List[Dict[str, Any]] = Field(..., description="Approved suggestions with modifications")
|
||||
|
||||
@@ -310,6 +310,19 @@ class AIOnboardingService:
|
||||
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",
|
||||
total_suggestions=len(suggestions),
|
||||
business_model=business_model.model,
|
||||
@@ -385,21 +398,68 @@ class AIOnboardingService:
|
||||
|
||||
try:
|
||||
# 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 = {
|
||||
"name": modifications.get("name") or approval.get("suggested_name"),
|
||||
"product_type": modifications.get("product_type") or approval.get("product_type"),
|
||||
"category": modifications.get("category") or approval.get("category"),
|
||||
"unit_of_measure": modifications.get("unit_of_measure") or approval.get("unit_of_measure"),
|
||||
"category": mapped_category,
|
||||
"unit_of_measure": mapped_unit,
|
||||
"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"),
|
||||
"requires_refrigeration": modifications.get("requires_refrigeration", approval.get("requires_refrigeration", False)),
|
||||
"requires_freezing": modifications.get("requires_freezing", approval.get("requires_freezing", False)),
|
||||
"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"
|
||||
# Optional fields
|
||||
"brand": modifications.get("brand") or approval.get("suggested_supplier"),
|
||||
"is_active": True
|
||||
}
|
||||
|
||||
# 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
|
||||
created_item = await self.inventory_client.create_ingredient(
|
||||
inventory_data, str(tenant_id)
|
||||
@@ -620,6 +680,47 @@ class AIOnboardingService:
|
||||
logger.warning("Failed to analyze product sales data", error=str(e))
|
||||
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
|
||||
def get_ai_onboarding_service() -> AIOnboardingService:
|
||||
|
||||
@@ -11,13 +11,16 @@ import base64
|
||||
import pandas as pd
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
import re
|
||||
import asyncio
|
||||
|
||||
from app.repositories.sales_repository import SalesRepository
|
||||
from app.models.sales import SalesData
|
||||
from app.schemas.sales import SalesDataCreate
|
||||
from app.core.database import get_db_transaction
|
||||
from app.services.inventory_client import InventoryServiceClient
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@@ -79,7 +82,10 @@ class DataImportService:
|
||||
|
||||
def __init__(self):
|
||||
"""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:
|
||||
"""Enhanced validation with better error handling and suggestions"""
|
||||
@@ -349,6 +355,9 @@ class DataImportService:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
# Clear cache for new import session
|
||||
self._clear_import_cache()
|
||||
|
||||
logger.info("Starting enhanced data import",
|
||||
filename=filename,
|
||||
format=file_format,
|
||||
@@ -451,12 +460,24 @@ class DataImportService:
|
||||
warnings.extend(parsed_data.get("warnings", []))
|
||||
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
|
||||
sales_data = SalesDataCreate(
|
||||
tenant_id=tenant_id,
|
||||
date=parsed_data["date"],
|
||||
product_name=parsed_data["product_name"],
|
||||
product_category=parsed_data.get("product_category"),
|
||||
inventory_product_id=inventory_product_id,
|
||||
quantity_sold=parsed_data["quantity_sold"],
|
||||
unit_price=parsed_data.get("unit_price"),
|
||||
revenue=parsed_data.get("revenue"),
|
||||
@@ -619,12 +640,24 @@ class DataImportService:
|
||||
warnings.extend(parsed_data.get("warnings", []))
|
||||
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
|
||||
sales_data = SalesDataCreate(
|
||||
tenant_id=tenant_id,
|
||||
date=parsed_data["date"],
|
||||
product_name=parsed_data["product_name"],
|
||||
product_category=parsed_data.get("product_category"),
|
||||
inventory_product_id=inventory_product_id,
|
||||
quantity_sold=parsed_data["quantity_sold"],
|
||||
unit_price=parsed_data.get("unit_price"),
|
||||
revenue=parsed_data.get("revenue"),
|
||||
@@ -874,6 +907,94 @@ class DataImportService:
|
||||
|
||||
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]]:
|
||||
"""Convert string messages to structured format"""
|
||||
structured = []
|
||||
|
||||
@@ -118,6 +118,20 @@ class InventoryServiceClient:
|
||||
error=str(e), category=category, tenant_id=tenant_id)
|
||||
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
|
||||
async def get_inventory_client() -> InventoryServiceClient:
|
||||
"""Get inventory service client instance"""
|
||||
|
||||
@@ -20,6 +20,7 @@ class Tenant(Base):
|
||||
name = Column(String(200), nullable=False)
|
||||
subdomain = Column(String(100), unique=True)
|
||||
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
|
||||
address = Column(Text, nullable=False)
|
||||
|
||||
@@ -17,6 +17,7 @@ class BakeryRegistration(BaseModel):
|
||||
postal_code: str = Field(..., pattern=r"^\d{5}$")
|
||||
phone: str = Field(..., min_length=9, max_length=20)
|
||||
business_type: str = Field(default="bakery")
|
||||
business_model: Optional[str] = Field(default="individual_bakery")
|
||||
|
||||
@validator('phone')
|
||||
def validate_spanish_phone(cls, v):
|
||||
@@ -42,12 +43,22 @@ class BakeryRegistration(BaseModel):
|
||||
raise ValueError(f'Business type must be one of: {valid_types}')
|
||||
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):
|
||||
"""Tenant response schema - FIXED VERSION"""
|
||||
id: str # ✅ Keep as str for Pydantic validation
|
||||
name: str
|
||||
subdomain: Optional[str]
|
||||
business_type: str
|
||||
business_model: Optional[str]
|
||||
address: str
|
||||
city: str
|
||||
postal_code: str
|
||||
@@ -101,6 +112,7 @@ class TenantUpdate(BaseModel):
|
||||
address: Optional[str] = Field(None, min_length=10, max_length=500)
|
||||
phone: Optional[str] = None
|
||||
business_type: Optional[str] = None
|
||||
business_model: Optional[str] = None
|
||||
|
||||
class TenantListResponse(BaseModel):
|
||||
"""Response schema for listing tenants"""
|
||||
|
||||
@@ -121,6 +121,7 @@ class BaseServiceSettings(BaseSettings):
|
||||
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")
|
||||
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")
|
||||
NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080")
|
||||
|
||||
@@ -331,6 +332,7 @@ class BaseServiceSettings(BaseSettings):
|
||||
"sales": self.SALES_SERVICE_URL,
|
||||
"external": self.EXTERNAL_SERVICE_URL,
|
||||
"tenant": self.TENANT_SERVICE_URL,
|
||||
"inventory": self.INVENTORY_SERVICE_URL,
|
||||
"notification": self.NOTIFICATION_SERVICE_URL,
|
||||
}
|
||||
|
||||
|
||||
162
test_onboarding_debug.py
Normal file
162
test_onboarding_debug.py
Normal 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
153
test_sales_import_fix.py
Normal 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())
|
||||
Reference in New Issue
Block a user