Files
bakery-ia/frontend/src/api/services/inventory.service.ts
2025-08-22 15:31:52 +02:00

749 lines
22 KiB
TypeScript

// 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;
}
// ========== 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<CreateInventoryItemRequest> {
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<T> {
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<PaginatedResponse<InventoryItem>> {
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<InventoryItem> {
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}`);
}
/**
* Create new inventory item
*/
async createInventoryItem(
tenantId: string,
data: CreateInventoryItemRequest
): Promise<InventoryItem> {
return apiClient.post(`/tenants/${tenantId}/ingredients`, data);
}
/**
* Update existing inventory item
*/
async updateInventoryItem(
tenantId: string,
itemId: string,
data: UpdateInventoryItemRequest
): Promise<InventoryItem> {
return apiClient.put(`/tenants/${tenantId}/ingredients/${itemId}`, data);
}
/**
* Delete inventory item (soft delete)
*/
async deleteInventoryItem(tenantId: string, itemId: string): Promise<void> {
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<StockLevel> {
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}/stock`);
}
/**
* Get stock levels for all items
*/
async getAllStockLevels(tenantId: string): Promise<StockLevel[]> {
// 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<StockMovement> {
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<PaginatedResponse<StockMovement>> {
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);
}
// ========== DASHBOARD & ANALYTICS ==========
/**
* 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<Blob> {
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<InventoryItem[]> {
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<string[]> {
return apiClient.get(`${this.baseEndpoint}/inventory/categories?type=${productType}`);
}
/**
* Get supplier suggestions
*/
async getSupplierSuggestions(tenantId: string): Promise<string[]> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`);
}
// ========== PRODUCTS FOR FORECASTING ==========
/**
* Get Products List with IDs for Forecasting
*/
async getProductsList(tenantId: string): Promise<ProductInfo[]> {
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<ProductInfo | null> {
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;
}
}
// ========== ENHANCED DASHBOARD FEATURES ==========
/**
* Get inventory dashboard data with analytics
*/
async getDashboardData(tenantId: string, params?: {
date_from?: string;
date_to?: string;
location?: string;
}): Promise<{
summary: {
total_items: number;
low_stock_count: number;
out_of_stock_items: number;
expiring_soon: number;
total_value: number;
};
recent_movements: any[];
active_alerts: any[];
stock_trends: {
dates: string[];
stock_levels: number[];
movements_in: number[];
movements_out: number[];
};
}> {
try {
return await apiClient.get(`/tenants/${tenantId}/inventory/dashboard`, { params });
} catch (error) {
console.error('❌ Error fetching inventory dashboard:', error);
throw error;
}
}
/**
* Get food safety compliance data
*/
async getFoodSafetyCompliance(tenantId: string): Promise<{
compliant_items: number;
non_compliant_items: number;
expiring_items: any[];
temperature_violations: any[];
compliance_score: number;
}> {
try {
return await apiClient.get(`/tenants/${tenantId}/inventory/food-safety/compliance`);
} catch (error) {
console.error('❌ Error fetching food safety compliance:', error);
throw error;
}
}
/**
* Get temperature monitoring data
*/
async getTemperatureMonitoring(tenantId: string, params?: {
item_id?: string;
location?: string;
date_from?: string;
date_to?: string;
}): Promise<{
readings: any[];
violations: any[];
}> {
try {
return await apiClient.get(`/tenants/${tenantId}/inventory/food-safety/temperature-monitoring`, { params });
} catch (error) {
console.error('❌ Error fetching temperature monitoring:', error);
throw error;
}
}
/**
* Record temperature reading
*/
async recordTemperatureReading(tenantId: string, params: {
item_id: string;
temperature: number;
humidity?: number;
location: string;
notes?: string;
}): Promise<void> {
try {
return await apiClient.post(`/tenants/${tenantId}/inventory/food-safety/temperature-reading`, params);
} catch (error) {
console.error('❌ Error recording temperature reading:', error);
throw error;
}
}
/**
* Get restock recommendations
*/
async getRestockRecommendations(tenantId: string): Promise<{
urgent_restocks: any[];
optimal_orders: any[];
}> {
try {
return await apiClient.get(`/tenants/${tenantId}/inventory/forecasting/restock-recommendations`);
} catch (error) {
console.error('❌ Error fetching restock recommendations:', error);
throw error;
}
}
}
export const inventoryService = new InventoryService();