erp-core-backend-v2/src/modules/inventory/reorder-alerts.service.ts
rckrdmrd edadaf3180 [FASE 3-4] feat: Complete Financial, Inventory, CRM, and Projects modules
EPIC-005 - Financial Module:
- Add AccountMapping entity for GL account configuration
- Create GLPostingService for automatic journal entries
- Integrate GL posting with invoice validation
- Fix tax calculation in invoice lines

EPIC-006 - Inventory Automation:
- Integrate FIFO valuation with pickings
- Create ReorderAlertsService for stock monitoring
- Add lot validation for tracked products
- Integrate valuation with inventory adjustments

EPIC-007 - CRM Improvements:
- Create ActivitiesService for activity management
- Create ForecastingService for pipeline analytics
- Add win/loss reporting and user performance metrics

EPIC-008 - Project Billing:
- Create BillingService with billing rate management
- Add getUnbilledTimesheets and createInvoiceFromTimesheets
- Support grouping options for invoice generation

EPIC-009 - HR-Projects Integration:
- Create HRIntegrationService for employee-user linking
- Add employee cost rate management
- Implement project profitability calculations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 05:49:20 -06:00

377 lines
12 KiB
TypeScript

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<ReorderAlert>(
`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<StockLevelReport>(
`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<StockSummary[]> {
let whereClause = `WHERE sq.tenant_id = $1`;
const params: any[] = [tenantId];
if (productId) {
whereClause += ` AND sq.product_id = $2`;
params.push(productId);
}
return query<StockSummary>(
`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<ReorderAlert | null> {
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<ReorderAlert>(
`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();