import { query, queryOne } from '../../config/database.js'; import { logger } from '../../shared/utils/logger.js'; // ============================================================================ // TYPES // ============================================================================ export interface ReorderAlert { product_id: string; product_code: string; product_name: string; warehouse_id?: string; warehouse_name?: string; current_quantity: number; reserved_quantity: number; available_quantity: number; reorder_point: number; reorder_quantity: number; min_stock: number; max_stock?: number; shortage: number; suggested_order_qty: number; alert_level: 'critical' | 'warning' | 'info'; } export interface StockLevelReport { product_id: string; product_code: string; product_name: string; warehouse_id: string; warehouse_name: string; location_id: string; location_name: string; quantity: number; reserved_quantity: number; available_quantity: number; lot_id?: string; lot_number?: string; uom_name: string; valuation: number; } export interface StockSummary { product_id: string; product_code: string; product_name: string; total_quantity: number; total_reserved: number; total_available: number; warehouse_count: number; location_count: number; total_valuation: number; } export interface ReorderAlertFilters { warehouse_id?: string; category_id?: string; alert_level?: 'critical' | 'warning' | 'info' | 'all'; page?: number; limit?: number; } export interface StockLevelFilters { product_id?: string; warehouse_id?: string; location_id?: string; include_zero?: boolean; page?: number; limit?: number; } // ============================================================================ // SERVICE // ============================================================================ class ReorderAlertsService { /** * Get all products below their reorder point * Checks inventory.stock_quants against products.products reorder settings */ async getReorderAlerts( tenantId: string, companyId: string, filters: ReorderAlertFilters = {} ): Promise<{ data: ReorderAlert[]; total: number }> { const { warehouse_id, category_id, alert_level = 'all', page = 1, limit = 50 } = filters; const offset = (page - 1) * limit; let whereClause = `WHERE p.tenant_id = $1 AND p.active = true`; const params: any[] = [tenantId]; let paramIndex = 2; if (warehouse_id) { whereClause += ` AND l.warehouse_id = $${paramIndex++}`; params.push(warehouse_id); } if (category_id) { whereClause += ` AND p.category_id = $${paramIndex++}`; params.push(category_id); } // Count total alerts const countResult = await queryOne<{ count: string }>( `SELECT COUNT(DISTINCT p.id) as count FROM products.products p LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id LEFT JOIN inventory.locations l ON sq.location_id = l.id ${whereClause} AND p.reorder_point IS NOT NULL AND COALESCE(sq.quantity, 0) - COALESCE(sq.reserved_quantity, 0) < p.reorder_point`, params ); // Get alerts with stock details params.push(limit, offset); const alerts = await query( `SELECT p.id as product_id, p.code as product_code, p.name as product_name, w.id as warehouse_id, w.name as warehouse_name, COALESCE(SUM(sq.quantity), 0)::numeric as current_quantity, COALESCE(SUM(sq.reserved_quantity), 0)::numeric as reserved_quantity, COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) as available_quantity, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock, p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0)) as shortage, COALESCE(p.reorder_quantity, p.reorder_point * 2) as suggested_order_qty, CASE WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 'critical' WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 'warning' ELSE 'info' END as alert_level FROM products.products p LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id LEFT JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal' LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id ${whereClause} AND p.reorder_point IS NOT NULL GROUP BY p.id, p.code, p.name, w.id, w.name, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock HAVING COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point ORDER BY CASE WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 1 WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 2 ELSE 3 END, (p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0))) DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); // Filter by alert level if specified const filteredAlerts = alert_level === 'all' ? alerts : alerts.filter(a => a.alert_level === alert_level); logger.info('Reorder alerts retrieved', { tenantId, companyId, totalAlerts: parseInt(countResult?.count || '0', 10), returnedAlerts: filteredAlerts.length, }); return { data: filteredAlerts, total: parseInt(countResult?.count || '0', 10), }; } /** * Get stock levels by product, warehouse, and location * TASK-006-03: Vista niveles de stock */ async getStockLevels( tenantId: string, filters: StockLevelFilters = {} ): Promise<{ data: StockLevelReport[]; total: number }> { const { product_id, warehouse_id, location_id, include_zero = false, page = 1, limit = 100 } = filters; const offset = (page - 1) * limit; let whereClause = `WHERE sq.tenant_id = $1`; const params: any[] = [tenantId]; let paramIndex = 2; if (product_id) { whereClause += ` AND sq.product_id = $${paramIndex++}`; params.push(product_id); } if (warehouse_id) { whereClause += ` AND l.warehouse_id = $${paramIndex++}`; params.push(warehouse_id); } if (location_id) { whereClause += ` AND sq.location_id = $${paramIndex++}`; params.push(location_id); } if (!include_zero) { whereClause += ` AND (sq.quantity != 0 OR sq.reserved_quantity != 0)`; } const countResult = await queryOne<{ count: string }>( `SELECT COUNT(*) as count FROM inventory.stock_quants sq JOIN inventory.locations l ON sq.location_id = l.id ${whereClause}`, params ); params.push(limit, offset); const data = await query( `SELECT sq.product_id, p.code as product_code, p.name as product_name, w.id as warehouse_id, w.name as warehouse_name, l.id as location_id, l.name as location_name, sq.quantity, sq.reserved_quantity, sq.quantity - sq.reserved_quantity as available_quantity, sq.lot_id, lot.name as lot_number, uom.name as uom_name, COALESCE(sq.quantity * p.cost_price, 0) as valuation FROM inventory.stock_quants sq JOIN inventory.products p ON sq.product_id = p.id JOIN inventory.locations l ON sq.location_id = l.id LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id LEFT JOIN inventory.lots lot ON sq.lot_id = lot.id LEFT JOIN core.uom uom ON p.uom_id = uom.id ${whereClause} ORDER BY p.name, w.name, l.name LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, params ); return { data, total: parseInt(countResult?.count || '0', 10), }; } /** * Get stock summary grouped by product */ async getStockSummary( tenantId: string, productId?: string ): Promise { let whereClause = `WHERE sq.tenant_id = $1`; const params: any[] = [tenantId]; if (productId) { whereClause += ` AND sq.product_id = $2`; params.push(productId); } return query( `SELECT p.id as product_id, p.code as product_code, p.name as product_name, SUM(sq.quantity) as total_quantity, SUM(sq.reserved_quantity) as total_reserved, SUM(sq.quantity - sq.reserved_quantity) as total_available, COUNT(DISTINCT l.warehouse_id) as warehouse_count, COUNT(DISTINCT sq.location_id) as location_count, COALESCE(SUM(sq.quantity * p.cost_price), 0) as total_valuation FROM inventory.stock_quants sq JOIN inventory.products p ON sq.product_id = p.id JOIN inventory.locations l ON sq.location_id = l.id ${whereClause} GROUP BY p.id, p.code, p.name ORDER BY p.name`, params ); } /** * Check if a specific product needs reorder */ async checkProductReorder( productId: string, tenantId: string, warehouseId?: string ): Promise { let whereClause = `WHERE p.id = $1 AND p.tenant_id = $2`; const params: any[] = [productId, tenantId]; if (warehouseId) { whereClause += ` AND l.warehouse_id = $3`; params.push(warehouseId); } const result = await queryOne( `SELECT p.id as product_id, p.code as product_code, p.name as product_name, w.id as warehouse_id, w.name as warehouse_name, COALESCE(SUM(sq.quantity), 0)::numeric as current_quantity, COALESCE(SUM(sq.reserved_quantity), 0)::numeric as reserved_quantity, COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) as available_quantity, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock, GREATEST(0, p.reorder_point - (COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0))) as shortage, COALESCE(p.reorder_quantity, p.reorder_point * 2) as suggested_order_qty, CASE WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) <= p.min_stock THEN 'critical' WHEN COALESCE(SUM(sq.quantity), 0) - COALESCE(SUM(sq.reserved_quantity), 0) < p.reorder_point THEN 'warning' ELSE 'info' END as alert_level FROM products.products p LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.inventory_product_id AND sq.tenant_id = p.tenant_id LEFT JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal' LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id ${whereClause} GROUP BY p.id, p.code, p.name, w.id, w.name, p.reorder_point, p.reorder_quantity, p.min_stock, p.max_stock`, params ); // Only return if below reorder point if (result && Number(result.available_quantity) < Number(result.reorder_point)) { return result; } return null; } /** * Get products with low stock for dashboard/notifications */ async getLowStockProductsCount( tenantId: string, companyId: string ): Promise<{ critical: number; warning: number; total: number }> { const result = await queryOne<{ critical: string; warning: string }>( `SELECT COUNT(DISTINCT CASE WHEN available <= p.min_stock THEN p.id END) as critical, COUNT(DISTINCT CASE WHEN available > p.min_stock AND available < p.reorder_point THEN p.id END) as warning FROM products.products p LEFT JOIN ( SELECT product_id, SUM(quantity) - SUM(reserved_quantity) as available FROM inventory.stock_quants sq JOIN inventory.locations l ON sq.location_id = l.id AND l.location_type = 'internal' WHERE sq.tenant_id = $1 GROUP BY product_id ) stock ON stock.product_id = p.inventory_product_id WHERE p.tenant_id = $1 AND p.active = true AND p.reorder_point IS NOT NULL`, [tenantId] ); const critical = parseInt(result?.critical || '0', 10); const warning = parseInt(result?.warning || '0', 10); return { critical, warning, total: critical + warning, }; } } export const reorderAlertsService = new ReorderAlertsService();