Files
bakery-ia/frontend/src/api/services/inventory.service.ts

628 lines
18 KiB
TypeScript
Raw Normal View History

// frontend/src/api/services/inventory.service.ts
/**
* Inventory Service
* Handles inventory management, stock tracking, and product operations
*/
import { apiClient } from '../client';
2025-08-15 17:53:59 +02:00
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;
2025-08-14 13:26:59 +02:00
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<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 {
2025-08-15 17:53:59 +02:00
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();
2025-08-15 17:53:59 +02:00
const url = `/tenants/${tenantId}/ingredients${query ? `?${query}` : ''}`;
2025-08-15 17:53:59 +02:00
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> {
2025-08-15 17:53:59 +02:00
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}`);
}
/**
* Create new inventory item
*/
async createInventoryItem(
tenantId: string,
data: CreateInventoryItemRequest
): Promise<InventoryItem> {
2025-08-15 17:53:59 +02:00
return apiClient.post(`/tenants/${tenantId}/ingredients`, data);
}
/**
* Update existing inventory item
*/
async updateInventoryItem(
tenantId: string,
itemId: string,
data: UpdateInventoryItemRequest
): Promise<InventoryItem> {
2025-08-15 17:53:59 +02:00
return apiClient.put(`/tenants/${tenantId}/ingredients/${itemId}`, data);
}
/**
* Delete inventory item (soft delete)
*/
async deleteInventoryItem(tenantId: string, itemId: string): Promise<void> {
2025-08-15 17:53:59 +02:00
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[] }> {
2025-08-15 17:53:59 +02:00
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> {
2025-08-15 17:53:59 +02:00
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}/stock`);
}
/**
* Get stock levels for all items
*/
async getAllStockLevels(tenantId: string): Promise<StockLevel[]> {
2025-08-15 17:53:59 +02:00
// 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(
2025-08-15 17:53:59 +02:00
`/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);
}
// ========== ALERTS ==========
/**
* Get current stock alerts
*/
async getStockAlerts(tenantId: string): Promise<StockAlert[]> {
2025-08-15 17:53:59 +02:00
// TODO: Map to correct endpoint when available
return [];
// return apiClient.get(`/tenants/${tenantId}/inventory/alerts`);
}
/**
* Acknowledge alert
*/
async acknowledgeAlert(tenantId: string, alertId: string): Promise<void> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts/${alertId}/acknowledge`);
}
/**
* Bulk acknowledge alerts
*/
async bulkAcknowledgeAlerts(tenantId: string, alertIds: string[]): Promise<void> {
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<InventoryDashboardData> {
2025-08-15 17:53:59 +02:00
// 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<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`);
}
2025-08-15 17:53:59 +02:00
// ========== PRODUCTS FOR FORECASTING ==========
/**
* Get Products List with IDs for Forecasting
*/
async getProductsList(tenantId: string): Promise<ProductInfo[]> {
try {
const response = await apiClient.get(`/tenants/${tenantId}/ingredients`, {
2025-08-16 08:22:51 +02:00
params: {
limit: 100,
product_type: 'finished_product' // Only get finished products, not raw ingredients
},
2025-08-15 17:53:59 +02:00
});
console.log('🔍 Inventory Products API Response:', response);
let productsArray: any[] = [];
if (Array.isArray(response)) {
productsArray = response;
} else if (response && typeof response === 'object') {
// Handle different response formats
const keys = Object.keys(response);
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
productsArray = Object.values(response);
} else {
console.warn('⚠️ Response is object but not with numeric keys:', response);
return [];
}
} else {
console.warn('⚠️ Response is not array or object:', response);
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 inventory products:', products);
return products;
} catch (error) {
console.error('❌ Failed to fetch inventory products:', error);
// Return empty array on error - 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;
}
}
}
export const inventoryService = new InventoryService();