// frontend/src/api/services/inventory.service.ts /** * Inventory Service * Handles inventory management, stock tracking, and product operations */ import { apiClient } from '../client'; import type { ProductInfo } from '../types'; // ========== TYPES AND INTERFACES ========== export type ProductType = 'ingredient' | 'finished_product'; export type UnitOfMeasure = | 'kilograms' | 'grams' | 'liters' | 'milliliters' | 'units' | 'pieces' | 'dozens' | 'boxes'; export type IngredientCategory = | 'flour' | 'yeast' | 'dairy' | 'eggs' | 'sugar' | 'fats' | 'salt' | 'spices' | 'additives' | 'packaging'; export type ProductCategory = | 'bread' | 'croissants' | 'pastries' | 'cakes' | 'cookies' | 'muffins' | 'sandwiches' | 'beverages' | 'other_products'; export type StockMovementType = | 'purchase' | 'consumption' | 'adjustment' | 'waste' | 'transfer' | 'return'; export interface InventoryItem { id: string; tenant_id: string; name: string; product_type: ProductType; category: IngredientCategory | ProductCategory; unit_of_measure: UnitOfMeasure; estimated_shelf_life_days?: number; requires_refrigeration: boolean; requires_freezing: boolean; is_seasonal: boolean; minimum_stock_level?: number; maximum_stock_level?: number; reorder_point?: number; supplier?: string; notes?: string; barcode?: string; sku?: string; cost_per_unit?: number; is_active: boolean; created_at: string; updated_at: string; // Computed fields current_stock?: StockLevel; low_stock_alert?: boolean; expiring_soon_alert?: boolean; recent_movements?: StockMovement[]; } export interface StockLevel { item_id: string; current_quantity: number; available_quantity: number; reserved_quantity: number; unit_of_measure: UnitOfMeasure; value_estimate?: number; last_updated: string; // Batch information batches?: StockBatch[]; oldest_batch_date?: string; newest_batch_date?: string; } export interface StockBatch { id: string; item_id: string; batch_number?: string; quantity: number; unit_cost?: number; purchase_date?: string; expiration_date?: string; supplier?: string; notes?: string; is_expired: boolean; days_until_expiration?: number; } export interface StockMovement { id: string; item_id: string; movement_type: StockMovementType; quantity: number; unit_cost?: number; total_cost?: number; batch_id?: string; reference_id?: string; notes?: string; movement_date: string; created_by: string; created_at: string; // Related data item_name?: string; batch_info?: StockBatch; } export interface StockAlert { id: string; item_id: string; alert_type: 'low_stock' | 'expired' | 'expiring_soon' | 'overstock'; severity: 'low' | 'medium' | 'high' | 'critical'; message: string; threshold_value?: number; current_value?: number; is_acknowledged: boolean; created_at: string; acknowledged_at?: string; acknowledged_by?: string; // Related data item?: InventoryItem; } // ========== REQUEST/RESPONSE TYPES ========== export interface CreateInventoryItemRequest { name: string; product_type: ProductType; category: IngredientCategory | ProductCategory; unit_of_measure: UnitOfMeasure; estimated_shelf_life_days?: number; requires_refrigeration?: boolean; requires_freezing?: boolean; is_seasonal?: boolean; minimum_stock_level?: number; maximum_stock_level?: number; reorder_point?: number; supplier?: string; notes?: string; barcode?: string; cost_per_unit?: number; } export interface UpdateInventoryItemRequest extends Partial { is_active?: boolean; } export interface StockAdjustmentRequest { movement_type: StockMovementType; quantity: number; unit_cost?: number; batch_number?: string; expiration_date?: string; supplier?: string; notes?: string; } export interface InventorySearchParams { search?: string; product_type?: ProductType; category?: string; is_active?: boolean; low_stock_only?: boolean; expiring_soon_only?: boolean; page?: number; limit?: number; sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at'; sort_order?: 'asc' | 'desc'; } export interface StockMovementSearchParams { item_id?: string; movement_type?: StockMovementType; date_from?: string; date_to?: string; page?: number; limit?: number; } export interface InventoryDashboardData { total_items: number; total_value: number; low_stock_count: number; expiring_soon_count: number; recent_movements: StockMovement[]; top_items_by_value: InventoryItem[]; category_breakdown: { category: string; count: number; value: number; }[]; movement_trends: { date: string; purchases: number; consumption: number; waste: number; }[]; } export interface PaginatedResponse { items: T[]; total: number; page: number; limit: number; total_pages: number; } // ========== INVENTORY SERVICE CLASS ========== export class InventoryService { private baseEndpoint = ''; // ========== INVENTORY ITEMS ========== /** * Get inventory items with filtering and pagination */ async getInventoryItems( tenantId: string, params?: InventorySearchParams ): Promise> { const searchParams = new URLSearchParams(); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { searchParams.append(key, value.toString()); } }); } const query = searchParams.toString(); const url = `/tenants/${tenantId}/ingredients${query ? `?${query}` : ''}`; console.log('🔍 InventoryService: Fetching inventory items from:', url); try { console.log('🔑 InventoryService: Making request with auth token:', localStorage.getItem('auth_token') ? 'Present' : 'Missing'); const response = await apiClient.get(url); console.log('📋 InventoryService: Raw response:', response); console.log('📋 InventoryService: Response type:', typeof response); console.log('📋 InventoryService: Response keys:', response ? Object.keys(response) : 'null'); // Handle different response formats if (Array.isArray(response)) { // Direct array response console.log('✅ InventoryService: Array response with', response.length, 'items'); return { items: response, total: response.length, page: 1, limit: response.length, total_pages: 1 }; } else if (response && typeof response === 'object') { // Check if it's already paginated if ('items' in response && Array.isArray(response.items)) { console.log('✅ InventoryService: Paginated response with', response.items.length, 'items'); return response; } // Handle object with numeric keys (convert to array) const keys = Object.keys(response); if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) { const items = Object.values(response); console.log('✅ InventoryService: Numeric keys response with', items.length, 'items'); return { items, total: items.length, page: 1, limit: items.length, total_pages: 1 }; } // Handle empty object - this seems to be what we're getting if (keys.length === 0) { console.log('📭 InventoryService: Empty object response - backend has no inventory items for this tenant'); throw new Error('NO_INVENTORY_ITEMS'); // This will trigger fallback in useInventory } } // Fallback: unexpected response format console.warn('⚠️ InventoryService: Unexpected response format, keys:', Object.keys(response || {})); throw new Error('UNEXPECTED_RESPONSE_FORMAT'); } catch (error) { console.error('❌ InventoryService: Failed to fetch inventory items:', error); throw error; } } /** * Get single inventory item by ID */ async getInventoryItem(tenantId: string, itemId: string): Promise { return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}`); } /** * Create new inventory item */ async createInventoryItem( tenantId: string, data: CreateInventoryItemRequest ): Promise { return apiClient.post(`/tenants/${tenantId}/ingredients`, data); } /** * Update existing inventory item */ async updateInventoryItem( tenantId: string, itemId: string, data: UpdateInventoryItemRequest ): Promise { return apiClient.put(`/tenants/${tenantId}/ingredients/${itemId}`, data); } /** * Delete inventory item (soft delete) */ async deleteInventoryItem(tenantId: string, itemId: string): Promise { return apiClient.delete(`/tenants/${tenantId}/ingredients/${itemId}`); } /** * Bulk update inventory items */ async bulkUpdateInventoryItems( tenantId: string, updates: { id: string; data: UpdateInventoryItemRequest }[] ): Promise<{ success: number; failed: number; errors: string[] }> { return apiClient.post(`/tenants/${tenantId}/ingredients/bulk-update`, { updates }); } // ========== STOCK MANAGEMENT ========== /** * Get current stock level for an item */ async getStockLevel(tenantId: string, itemId: string): Promise { return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}/stock`); } /** * Get stock levels for all items */ async getAllStockLevels(tenantId: string): Promise { // TODO: Map to correct endpoint when available return []; // return apiClient.get(`/stock/summary`); } /** * Adjust stock level (purchase, consumption, waste, etc.) */ async adjustStock( tenantId: string, itemId: string, adjustment: StockAdjustmentRequest ): Promise { return apiClient.post( `/stock/consume`, adjustment ); } /** * Bulk stock adjustments */ async bulkAdjustStock( tenantId: string, adjustments: { item_id: string; adjustment: StockAdjustmentRequest }[] ): Promise<{ success: number; failed: number; movements: StockMovement[]; errors: string[] }> { return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/bulk-adjust`, { adjustments }); } /** * Get stock movements with filtering */ async getStockMovements( tenantId: string, params?: StockMovementSearchParams ): Promise> { const searchParams = new URLSearchParams(); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { searchParams.append(key, value.toString()); } }); } const query = searchParams.toString(); const url = `${this.baseEndpoint}/tenants/${tenantId}/inventory/movements${query ? `?${query}` : ''}`; return apiClient.get(url); } // ========== ALERTS ========== /** * Get current stock alerts */ async getStockAlerts(tenantId: string): Promise { // TODO: Map to correct endpoint when available return []; // return apiClient.get(`/tenants/${tenantId}/inventory/alerts`); } /** * Acknowledge alert */ async acknowledgeAlert(tenantId: string, alertId: string): Promise { return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts/${alertId}/acknowledge`); } /** * Bulk acknowledge alerts */ async bulkAcknowledgeAlerts(tenantId: string, alertIds: string[]): Promise { return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts/bulk-acknowledge`, { alert_ids: alertIds }); } // ========== DASHBOARD & ANALYTICS ========== /** * Get inventory dashboard data */ async getDashboardData(tenantId: string): Promise { // TODO: Map to correct endpoint when available return { total_items: 0, low_stock_items: 0, out_of_stock_items: 0, total_value: 0, recent_movements: [], top_products: [], stock_alerts: [] }; // return apiClient.get(`/tenants/${tenantId}/inventory/dashboard`); } /** * Get inventory value report */ async getInventoryValue(tenantId: string): Promise<{ total_value: number; by_category: { category: string; value: number; percentage: number }[]; by_product_type: { type: ProductType; value: number; percentage: number }[]; }> { return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/value`); } /** * Get low stock report */ async getLowStockReport(tenantId: string): Promise<{ items: InventoryItem[]; total_affected: number; estimated_loss: number; }> { return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/low-stock`); } /** * Get expiring items report */ async getExpiringItemsReport(tenantId: string, days?: number): Promise<{ items: (InventoryItem & { batches: StockBatch[] })[]; total_affected: number; estimated_loss: number; }> { const params = days ? `?days=${days}` : ''; return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/expiring${params}`); } // ========== IMPORT/EXPORT ========== /** * Export inventory data to CSV */ async exportInventory(tenantId: string, format: 'csv' | 'excel' = 'csv'): Promise { const response = await apiClient.getRaw( `${this.baseEndpoint}/tenants/${tenantId}/inventory/export?format=${format}` ); return response.blob(); } /** * Import inventory from file */ async importInventory(tenantId: string, file: File): Promise<{ success: number; failed: number; errors: string[]; created_items: InventoryItem[]; }> { const formData = new FormData(); formData.append('file', file); return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/import`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, }); } // ========== SEARCH & SUGGESTIONS ========== /** * Search inventory items with autocomplete */ async searchItems(tenantId: string, query: string, limit = 10): Promise { return apiClient.get( `${this.baseEndpoint}/tenants/${tenantId}/inventory/search?q=${encodeURIComponent(query)}&limit=${limit}` ); } /** * Get category suggestions based on product type */ async getCategorySuggestions(productType: ProductType): Promise { return apiClient.get(`${this.baseEndpoint}/inventory/categories?type=${productType}`); } /** * Get supplier suggestions */ async getSupplierSuggestions(tenantId: string): Promise { return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`); } // ========== PRODUCTS FOR FORECASTING ========== /** * Get Products List with IDs for Forecasting */ async getProductsList(tenantId: string): Promise { try { console.log('🔍 Fetching products for forecasting...', { tenantId }); // First try to get finished products (preferred for forecasting) const response = await apiClient.get(`/tenants/${tenantId}/ingredients`, { params: { limit: 100, product_type: 'finished_product' }, }); console.log('🔍 Inventory Products API Response:', response); console.log('🔍 Raw response data:', response.data); console.log('🔍 Response status:', response.status); console.log('🔍 Response headers:', response.headers); console.log('🔍 Full response object keys:', Object.keys(response || {})); console.log('🔍 Response data type:', typeof response); console.log('🔍 Response data constructor:', response?.constructor?.name); // Check if response.data exists and what type it is if (response && 'data' in response) { console.log('🔍 Response.data exists:', typeof response.data); console.log('🔍 Response.data keys:', Object.keys(response.data || {})); console.log('🔍 Response.data constructor:', response.data?.constructor?.name); } let productsArray: any[] = []; // Check response.data first (typical API client behavior) const dataToProcess = response?.data || response; if (Array.isArray(dataToProcess)) { productsArray = dataToProcess; console.log('✅ Found array data with', productsArray.length, 'items'); } else if (dataToProcess && typeof dataToProcess === 'object') { // Handle different response formats const keys = Object.keys(dataToProcess); if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) { productsArray = Object.values(dataToProcess); console.log('✅ Found object with numeric keys, converted to array with', productsArray.length, 'items'); } else { console.warn('⚠️ Response is object but not with numeric keys:', dataToProcess); console.warn('⚠️ Object keys:', keys); return []; } } else { console.warn('⚠️ Response data is not array or object:', dataToProcess); return []; } // Convert to ProductInfo objects const products: ProductInfo[] = productsArray .map((product: any) => ({ inventory_product_id: product.id || product.inventory_product_id, name: product.name || product.product_name || `Product ${product.id || ''}`, category: product.category, // Add additional fields if available from inventory current_stock: product.current_stock, unit: product.unit, cost_per_unit: product.cost_per_unit })) .filter(product => product.inventory_product_id && product.name); console.log('📋 Processed finished products:', products); // If no finished products found, try to get all products as fallback if (products.length === 0) { console.log('⚠️ No finished products found, trying to get all products as fallback...'); const fallbackResponse = await apiClient.get(`/tenants/${tenantId}/ingredients`, { params: { limit: 100, // No product_type filter to get all products }, }); console.log('🔍 Fallback API Response:', fallbackResponse); const fallbackDataToProcess = fallbackResponse?.data || fallbackResponse; let fallbackProductsArray: any[] = []; if (Array.isArray(fallbackDataToProcess)) { fallbackProductsArray = fallbackDataToProcess; } else if (fallbackDataToProcess && typeof fallbackDataToProcess === 'object') { const keys = Object.keys(fallbackDataToProcess); if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) { fallbackProductsArray = Object.values(fallbackDataToProcess); } } const fallbackProducts: ProductInfo[] = fallbackProductsArray .map((product: any) => ({ inventory_product_id: product.id || product.inventory_product_id, name: product.name || product.product_name || `Product ${product.id || ''}`, category: product.category, current_stock: product.current_stock, unit: product.unit, cost_per_unit: product.cost_per_unit })) .filter(product => product.inventory_product_id && product.name); console.log('📋 Processed fallback products (all inventory items):', fallbackProducts); return fallbackProducts; } return products; } catch (error) { console.error('❌ Failed to fetch inventory products:', error); console.error('❌ Error details:', { message: error instanceof Error ? error.message : 'Unknown error', response: (error as any)?.response, status: (error as any)?.response?.status, data: (error as any)?.response?.data }); // If it's an authentication error, throw it to trigger auth flow if ((error as any)?.response?.status === 401) { throw error; } // Return empty array on other errors - let dashboard handle fallback return []; } } /** * Get Product by ID */ async getProductById(tenantId: string, productId: string): Promise { try { const response = await apiClient.get(`/tenants/${tenantId}/ingredients/${productId}`); if (response) { return { inventory_product_id: response.id || response.inventory_product_id, name: response.name || response.product_name, category: response.category, current_stock: response.current_stock, unit: response.unit, cost_per_unit: response.cost_per_unit }; } return null; } catch (error) { console.error('❌ Failed to fetch product by ID:', error); return null; } } } export const inventoryService = new InventoryService();