const { pool } = require('../db'); const DEFAULT_SUPPLY_LOOKBACK_DAYS = 30; const DEFAULT_SUPPLY_COVERAGE_DAYS = 30; const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); const normalizeNumber = (value) => { if (value === '' || value === null || value === undefined) return null; const number = Number(String(value).replace(',', '.')); return Number.isFinite(number) && number > 0 ? number : null; }; const normalizeNonNegativeNumber = (value) => { if (value === '' || value === null || value === undefined) return null; const number = Number(String(value).replace(',', '.')); return Number.isFinite(number) && number >= 0 ? number : null; }; const normalizeKey = (value) => normalizeText(value) .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); const normalizeSku = (value) => normalizeText(value).toUpperCase(); const mapReceipt = (row) => ({ id: row.id, category: row.category, product: row.product, quantity: Number(row.quantity), unit: row.unit, supplier: row.supplier || '', invoice: row.invoice || '', notes: row.notes || '', status: row.status, createdAt: row.created_at, updatedAt: row.updated_at, approvedAt: row.approved_at }); const mapLot = (row) => ({ id: row.id, receiptId: row.receipt_id, category: row.category, product: row.product, quantity: Number(row.quantity), unit: row.unit, supplier: row.supplier || '', invoice: row.invoice || '', status: row.status, createdAt: row.created_at, updatedAt: row.updated_at }); const mapMovement = (row) => ({ id: row.id, receiptId: row.receipt_id, lotId: row.lot_id, type: row.type, category: row.category, product: row.product, quantity: Number(row.quantity), unit: row.unit, reason: row.reason || '', createdAt: row.created_at }); const mapFabricPlan = (row) => ({ id: row.id, material: row.material, color: row.color || '', quantityKg: Number(row.quantity_kg), supplier: row.supplier || '', priority: row.priority, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at }); const createValidationError = (message) => { const error = new Error(message); error.statusCode = 400; return error; }; const listReceipts = async () => { const result = await pool.query(` SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at FROM supply_receipts ORDER BY created_at DESC, id DESC `); return result.rows.map(mapReceipt); }; const listLots = async () => { const result = await pool.query(` SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at FROM supply_stock_lots WHERE status = 'active' ORDER BY created_at DESC, id DESC `); return result.rows.map(mapLot); }; const listMovements = async () => { const result = await pool.query(` SELECT id, receipt_id, lot_id, type, category, product, quantity, unit, reason, created_at FROM supply_movements ORDER BY created_at DESC, id DESC `); return result.rows.map(mapMovement); }; const listFabricPlans = async () => { const result = await pool.query(` SELECT id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at FROM supply_fabric_plans WHERE status = 'active' ORDER BY CASE priority WHEN 'Crítico' THEN 1 WHEN 'Atenção' THEN 2 ELSE 3 END, created_at DESC, id DESC `); return result.rows.map(mapFabricPlan); }; const buildPurchaseNeeds = (plans, lots, receipts) => { const needsByMaterial = new Map(); plans.forEach(plan => { const key = normalizeKey(plan.material); if (!key) return; const current = needsByMaterial.get(key) || { material: plan.material, plannedKg: 0, stockKg: 0, pendingKg: 0, purchaseKg: 0, priority: 'Normal', suppliers: new Set(), colors: new Set() }; current.plannedKg += plan.quantityKg; if (plan.supplier) current.suppliers.add(plan.supplier); if (plan.color) current.colors.add(plan.color); if (plan.priority === 'Crítico') current.priority = 'Crítico'; if (plan.priority === 'Atenção' && current.priority !== 'Crítico') current.priority = 'Atenção'; needsByMaterial.set(key, current); }); lots.forEach(lot => { if (lot.unit !== 'kg') return; const need = needsByMaterial.get(normalizeKey(lot.product)); if (need) need.stockKg += lot.quantity; }); receipts.forEach(receipt => { if (receipt.status !== 'pending' || receipt.unit !== 'kg') return; const need = needsByMaterial.get(normalizeKey(receipt.product)); if (need) need.pendingKg += receipt.quantity; }); return Array.from(needsByMaterial.values()) .map(need => { const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0); let status = 'ok'; if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical'; else if (purchaseKg > 0) status = 'attention'; return { material: need.material, plannedKg: need.plannedKg, stockKg: need.stockKg, pendingKg: need.pendingKg, purchaseKg, priority: need.priority, status, suppliers: Array.from(need.suppliers), colors: Array.from(need.colors) }; }) .sort((a, b) => { const statusOrder = { critical: 1, attention: 2, ok: 3 }; return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material); }); }; const getReferenceYield = (reference) => { if (reference.general_yield !== null && Number(reference.general_yield) > 0) { return Number(reference.general_yield); } const sizeYields = reference.size_yields && typeof reference.size_yields === 'object' ? Object.values(reference.size_yields).map(Number).filter(value => Number.isFinite(value) && value > 0) : []; if (!sizeYields.length) return null; return sizeYields.reduce((total, value) => total + value, 0) / sizeYields.length; }; const listProjectDemandRows = async () => { const result = await pool.query(` WITH bounds AS ( SELECT MAX(data_pedido_date) AS end_date FROM orders WHERE data_pedido_date IS NOT NULL ), period_orders AS ( SELECT produto_id, MAX(produto_descricao) AS product_name, SUM(quantidade)::numeric AS quantity_sold FROM orders, bounds WHERE data_pedido_date IS NOT NULL AND bounds.end_date IS NOT NULL AND data_pedido_date >= (bounds.end_date - ($1::int - 1) * INTERVAL '1 day')::date AND data_pedido_date <= bounds.end_date GROUP BY produto_id ) SELECT COALESCE(period_orders.produto_id, stock.produto_id) AS product_id, COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name, COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold, COALESCE(stock.saldo, 0)::numeric AS stock_quantity FROM period_orders FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> '' ORDER BY quantity_sold DESC, product_name; `, [DEFAULT_SUPPLY_LOOKBACK_DAYS]); return result.rows; }; const listConsumptionReferenceRows = async () => { const result = await pool.query(` SELECT r.product_id, p.sku AS product_sku, p.name AS product_name, r.material_product_id, m.sku AS material_sku, m.name AS material_name, r.color, r.general_yield, r.size_yields FROM consumption_references r JOIN catalog_products p ON p.id = r.product_id LEFT JOIN catalog_products m ON m.id = r.material_product_id ORDER BY p.sku, r.color NULLS FIRST; `); return result.rows; }; const mergeNeedLine = (needsByMaterial, key, patch) => { const current = needsByMaterial.get(key) || { material: patch.material, plannedKg: 0, stockKg: 0, pendingKg: 0, purchaseKg: 0, priority: patch.priority || 'Normal', suppliers: new Set(), colors: new Set(), unit: patch.unit || 'kg', source: patch.source || 'manual_plan', missingReference: Boolean(patch.missingReference), products: [] }; current.plannedKg += patch.plannedKg || 0; current.priority = patch.priority === 'Crítico' || current.priority === 'Crítico' ? 'Crítico' : patch.priority === 'Atenção' || current.priority === 'Atenção' ? 'Atenção' : current.priority; current.missingReference = current.missingReference || Boolean(patch.missingReference); current.source = current.source === patch.source ? current.source : 'mixed'; if (patch.supplier) current.suppliers.add(patch.supplier); if (patch.color) current.colors.add(patch.color); if (patch.product) current.products.push(patch.product); needsByMaterial.set(key, current); return current; }; const buildProjectPurchaseNeeds = async (lots, receipts) => { const [demandRows, referenceRows] = await Promise.all([ listProjectDemandRows(), listConsumptionReferenceRows() ]); const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference])); const needsByMaterial = new Map(); demandRows.forEach(row => { const productId = normalizeSku(row.product_id); if (!productId) return; const quantitySold = Number(row.quantity_sold || 0); const stockQuantity = Number(row.stock_quantity || 0); const projectedDemand = quantitySold * (DEFAULT_SUPPLY_COVERAGE_DAYS / DEFAULT_SUPPLY_LOOKBACK_DAYS); const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0); if (suggestedQuantity <= 0) return; const reference = referencesBySku.get(productId); if (!reference) { mergeNeedLine(needsByMaterial, `missing:${productId}`, { material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`, plannedKg: suggestedQuantity, priority: 'Crítico', unit: 'un.', source: 'project_demand', missingReference: true, product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity } }); return; } const yieldPerKg = getReferenceYield(reference); if (!yieldPerKg) { mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, { material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`, plannedKg: suggestedQuantity, priority: 'Crítico', unit: 'un.', source: 'project_demand', missingReference: true, color: reference.color, product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity } }); return; } const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro'; mergeNeedLine(needsByMaterial, normalizeKey(materialName), { material: materialName, plannedKg: suggestedQuantity / yieldPerKg, priority: 'Atenção', unit: 'kg', source: 'project_demand', color: reference.color, product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity, yieldPerKg } }); }); lots.forEach(lot => { if (lot.unit !== 'kg') return; const need = needsByMaterial.get(normalizeKey(lot.product)); if (need && need.unit === 'kg') need.stockKg += lot.quantity; }); receipts.forEach(receipt => { if (receipt.status !== 'pending' || receipt.unit !== 'kg') return; const need = needsByMaterial.get(normalizeKey(receipt.product)); if (need && need.unit === 'kg') need.pendingKg += receipt.quantity; }); return Array.from(needsByMaterial.values()).map(need => { const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0); let status = 'ok'; if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical'; else if (purchaseKg > 0) status = 'attention'; return { material: need.material, plannedKg: need.plannedKg, stockKg: need.stockKg, pendingKg: need.pendingKg, purchaseKg, priority: need.priority, status, suppliers: Array.from(need.suppliers), colors: Array.from(need.colors), unit: need.unit, source: need.source, missingReference: need.missingReference, products: need.products }; }); }; const mergePurchaseNeeds = (manualNeeds, projectNeeds) => { const mergedByKey = new Map(); [...manualNeeds, ...projectNeeds].forEach(need => { const key = `${need.unit || 'kg'}:${normalizeKey(need.material)}:${need.missingReference ? 'missing' : 'mapped'}`; const current = mergedByKey.get(key); if (!current) { mergedByKey.set(key, { ...need, suppliers: new Set(need.suppliers || []), colors: new Set(need.colors || []), products: [...(need.products || [])] }); return; } current.plannedKg += need.plannedKg; current.stockKg += need.stockKg; current.pendingKg += need.pendingKg; current.purchaseKg += need.purchaseKg; current.priority = need.priority === 'Crítico' || current.priority === 'Crítico' ? 'Crítico' : need.priority === 'Atenção' || current.priority === 'Atenção' ? 'Atenção' : current.priority; current.status = current.status === 'critical' || need.status === 'critical' ? 'critical' : current.status === 'attention' || need.status === 'attention' ? 'attention' : 'ok'; current.source = current.source === need.source ? current.source : 'mixed'; current.missingReference = current.missingReference || Boolean(need.missingReference); (need.suppliers || []).forEach(supplier => current.suppliers.add(supplier)); (need.colors || []).forEach(color => current.colors.add(color)); current.products.push(...(need.products || [])); }); return Array.from(mergedByKey.values()) .map(need => ({ ...need, suppliers: Array.from(need.suppliers), colors: Array.from(need.colors) })) .sort((a, b) => { const statusOrder = { critical: 1, attention: 2, ok: 3 }; return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material); }); }; const buildStats = (receipts, lots, purchaseNeeds) => { const totalQuantityKg = lots.reduce((total, lot) => ( lot.unit === 'kg' ? total + lot.quantity : total ), 0); return { totalQuantityKg, activeLots: lots.length, rolls: lots.filter(lot => lot.unit === 'rolos').reduce((total, lot) => total + lot.quantity, 0), alerts: purchaseNeeds.filter(need => need.status !== 'ok').length, pendingReceipts: receipts.filter(receipt => receipt.status === 'pending').length, approvedReceipts: receipts.filter(receipt => receipt.status === 'approved').length }; }; const getSupplySummary = async () => { const [receipts, lots, movements, fabricPlans] = await Promise.all([ listReceipts(), listLots(), listMovements(), listFabricPlans() ]); const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts); const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts); const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds); return { receipts, lots, movements, fabricPlans, purchaseNeeds, stats: buildStats(receipts, lots, purchaseNeeds) }; }; const listPurchaseNeeds = async () => { const [plans, lots, receipts] = await Promise.all([ listFabricPlans(), listLots(), listReceipts() ]); return mergePurchaseNeeds( buildPurchaseNeeds(plans, lots, receipts), await buildProjectPurchaseNeeds(lots, receipts) ); }; const createReceipt = async (payload) => { const category = normalizeText(payload.category); const product = normalizeText(payload.product); const quantity = normalizeNumber(payload.quantity); const unit = normalizeText(payload.unit) || 'kg'; if (!category) throw createValidationError('Categoria é obrigatória.'); if (!product) throw createValidationError('Produto ou material é obrigatório.'); if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.'); const result = await pool.query(` INSERT INTO supply_receipts ( category, product, quantity, unit, supplier, invoice, notes, status, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at `, [ category, product, quantity, unit, normalizeText(payload.supplier) || null, normalizeText(payload.invoice) || null, normalizeText(payload.notes) || null ]); return mapReceipt(result.rows[0]); }; const approveReceipt = async (id) => { const client = await pool.connect(); try { await client.query('BEGIN'); const receiptResult = await client.query(` SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at FROM supply_receipts WHERE id = $1 FOR UPDATE `, [id]); if (!receiptResult.rowCount) { throw createValidationError('Recebimento não encontrado.'); } const receipt = receiptResult.rows[0]; if (receipt.status === 'approved') { await client.query('COMMIT'); return mapReceipt(receipt); } const updatedReceiptResult = await client.query(` UPDATE supply_receipts SET status = 'approved', approved_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at `, [id]); const lotResult = await client.query(` INSERT INTO supply_stock_lots ( receipt_id, category, product, quantity, unit, supplier, invoice, status, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', CURRENT_TIMESTAMP) RETURNING id `, [ receipt.id, receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice ]); await client.query(` INSERT INTO supply_movements ( receipt_id, lot_id, type, category, product, quantity, unit, reason ) VALUES ($1, $2, 'receipt', $3, $4, $5, $6, $7) `, [ receipt.id, lotResult.rows[0].id, receipt.category, receipt.product, receipt.quantity, receipt.unit, `Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}` ]); await client.query('COMMIT'); return mapReceipt(updatedReceiptResult.rows[0]); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } }; const deleteReceipt = async (id) => { const client = await pool.connect(); try { await client.query('BEGIN'); await client.query('DELETE FROM supply_movements WHERE receipt_id = $1', [id]); await client.query('DELETE FROM supply_stock_lots WHERE receipt_id = $1', [id]); await client.query('DELETE FROM supply_receipts WHERE id = $1', [id]); await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } }; const createFabricPlan = async (payload) => { const material = normalizeText(payload.material); const quantityKg = normalizeNumber(payload.quantityKg); if (!material) throw createValidationError('Malha ou tecido é obrigatório.'); if (!quantityKg) throw createValidationError('Quantidade deve ser maior que zero.'); const result = await pool.query(` INSERT INTO supply_fabric_plans ( material, color, quantity_kg, supplier, priority, status, updated_at ) VALUES ($1, $2, $3, $4, $5, 'active', CURRENT_TIMESTAMP) RETURNING id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at `, [ material, normalizeText(payload.color) || 'Todas as cores', quantityKg, normalizeText(payload.supplier) || null, normalizeText(payload.priority) || 'Normal' ]); return mapFabricPlan(result.rows[0]); }; const deleteFabricPlan = async (id) => { await pool.query(` UPDATE supply_fabric_plans SET status = 'removed', updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, [id]); }; const updateLotQuantity = async (client, lotId, quantity) => { const status = quantity > 0 ? 'active' : 'depleted'; const result = await client.query(` UPDATE supply_stock_lots SET quantity = $2, status = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at `, [lotId, quantity, status]); return mapLot(result.rows[0]); }; const adjustInventoryLot = async (id, payload) => { const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity); const reason = normalizeText(payload.reason); if (countedQuantity === null) throw createValidationError('Quantidade contada deve ser zero ou maior.'); if (!reason) throw createValidationError('Justificativa do ajuste é obrigatória.'); const client = await pool.connect(); try { await client.query('BEGIN'); const lotResult = await client.query(` SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at FROM supply_stock_lots WHERE id = $1 FOR UPDATE `, [id]); if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.'); const lot = lotResult.rows[0]; const currentQuantity = Number(lot.quantity); const difference = countedQuantity - currentQuantity; const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity); await client.query(` INSERT INTO supply_movements ( lot_id, type, category, product, quantity, unit, reason ) VALUES ($1, 'inventory_adjustment', $2, $3, $4, $5, $6) `, [ lot.id, lot.category, lot.product, difference, lot.unit, `${reason} · sistema ${currentQuantity} ${lot.unit} · contado ${countedQuantity} ${lot.unit}` ]); await client.query('COMMIT'); return updatedLot; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } }; const consumeLotForProduction = async (id, payload) => { const quantity = normalizeNumber(payload.quantity); const reason = normalizeText(payload.reason); const productionOrderNumber = normalizeText(payload.productionOrderNumber); if (!quantity) throw createValidationError('Quantidade de saída deve ser maior que zero.'); if (!productionOrderNumber && !reason) throw createValidationError('Informe a OP ou uma justificativa para a saída.'); const client = await pool.connect(); try { await client.query('BEGIN'); const lotResult = await client.query(` SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at FROM supply_stock_lots WHERE id = $1 FOR UPDATE `, [id]); if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.'); const lot = lotResult.rows[0]; const currentQuantity = Number(lot.quantity); if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.'); if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.'); const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity); const movementReason = [ productionOrderNumber ? `OP ${productionOrderNumber}` : '', reason ].filter(Boolean).join(' · '); await client.query(` INSERT INTO supply_movements ( lot_id, type, category, product, quantity, unit, reason ) VALUES ($1, 'production_exit', $2, $3, $4, $5, $6) `, [ lot.id, lot.category, lot.product, -quantity, lot.unit, movementReason || 'Saída para produção' ]); await client.query('COMMIT'); return updatedLot; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } }; module.exports = { adjustInventoryLot, approveReceipt, consumeLotForProduction, createFabricPlan, createReceipt, deleteFabricPlan, deleteReceipt, getSupplySummary, buildProjectPurchaseNeeds, buildPurchaseNeeds, listFabricPlans, listLots, listMovements, listPurchaseNeeds, listReceipts };