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>
377 lines
12 KiB
TypeScript
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();
|