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; }; // Tiny may temporarily report a negative balance while its own adjustments are // being reconciled. Negative inventory must not inflate the material demand: // for planning, it represents no available stock, never an extra shortage. const getAvailablePlanningStock = (value) => Math.max(0, Number(value) || 0); const normalizeKey = (value) => normalizeText(value) .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); const normalizeSku = (value) => normalizeText(value).toUpperCase(); const normalizeUnit = (value) => { const unit = normalizeText(value).toLowerCase(); if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg'; if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.'; return unit || 'un.'; }; 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, reservedQuantity: Number(row.reserved_quantity || 0), availableQuantity: Number(row.available_quantity === undefined ? row.quantity : row.available_quantity), 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 mapMaterialTransformation = (row) => ({ id: row.id, batchCode: row.batch_code || '', stage: row.stage, inputMaterial: row.input_material, inputQuantityKg: Number(row.input_quantity_kg), outputMaterial: row.output_material, outputColor: row.output_color || '', expectedLossPercent: Number(row.expected_loss_percent || 0), expectedOutputKg: Number(row.expected_output_kg || 0), supplier: row.supplier || '', priority: row.priority || 'Normal', status: row.status || 'planned', notes: row.notes || '', createdAt: row.created_at, updatedAt: row.updated_at }); const mapMaterialDetails = (row) => ({ materialKey: row.material_key, displayName: row.display_name || '', preferredSupplier: row.preferred_supplier || '', notes: row.notes || '', createdAt: row.created_at, updatedAt: row.updated_at }); const PURCHASE_ORDER_STATUSES = new Set(['draft', 'sent', 'approved', 'received', 'canceled']); const mapPurchaseOrder = (row) => ({ id: row.id, number: row.number || '', supplier: row.supplier || '', status: row.status || 'draft', expectedDate: row.expected_date ? String(row.expected_date).slice(0, 10) : null, notes: row.notes || '', total: Number(row.total || 0), items: Array.isArray(row.items) ? row.items.map(item => ({ id: item.id, material: item.material || '', quantity: Number(item.quantity || 0), unit: item.unit || 'kg', unitCost: item.unit_cost === null || item.unit_cost === undefined ? null : Number(item.unit_cost) })) : [], 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 lot.id, lot.receipt_id, lot.category, lot.product, lot.quantity, lot.unit, lot.supplier, lot.invoice, lot.status, lot.created_at, lot.updated_at, COALESCE(reservations.reserved_quantity, 0) AS reserved_quantity, GREATEST(0, lot.quantity - COALESCE(reservations.reserved_quantity, 0)) AS available_quantity FROM supply_stock_lots lot LEFT JOIN LATERAL ( SELECT COALESCE(SUM(quantity - consumed_quantity), 0) AS reserved_quantity FROM cut_plan_lot_reservations reservation WHERE reservation.supply_lot_id = lot.id AND reservation.status = 'active' ) reservations ON TRUE WHERE lot.status = 'active' ORDER BY lot.created_at DESC, lot.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 listMaterialTransformations = async () => { const result = await pool.query(` SELECT id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color, expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at FROM supply_material_transformations ORDER BY CASE status WHEN 'in_progress' THEN 1 WHEN 'planned' THEN 2 WHEN 'completed' THEN 3 ELSE 4 END, CASE priority WHEN 'Crítico' THEN 1 WHEN 'Atenção' THEN 2 ELSE 3 END, created_at DESC, id DESC `); return result.rows.map(mapMaterialTransformation); }; const listMaterialDetails = async () => { const result = await pool.query(` SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at FROM supply_material_details `); return result.rows.map(mapMaterialDetails); }; const listKnownSuppliers = async () => { const result = await pool.query(` SELECT DISTINCT supplier FROM ( SELECT preferred_supplier AS supplier FROM supply_material_details UNION ALL SELECT supplier FROM supply_stock_lots UNION ALL SELECT supplier FROM supply_receipts UNION ALL SELECT supplier FROM supply_fabric_plans UNION ALL SELECT supplier FROM supply_purchase_orders UNION ALL SELECT supplier FROM production_orders UNION ALL SELECT supplier FROM production_yield_supplier_recommendations ) sources WHERE NULLIF(BTRIM(supplier), '') IS NOT NULL ORDER BY supplier; `); return result.rows.map(row => row.supplier); }; const listPurchaseOrders = async () => { const result = await pool.query(` SELECT purchase_order.id, purchase_order.number, purchase_order.supplier, purchase_order.status, purchase_order.expected_date, purchase_order.notes, purchase_order.created_at, purchase_order.updated_at, COALESCE((SELECT SUM(item.quantity * COALESCE(item.unit_cost, 0)) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), 0) AS total, COALESCE((SELECT JSON_AGG(JSON_BUILD_OBJECT('id', item.id, 'material', item.material, 'quantity', item.quantity, 'unit', item.unit, 'unit_cost', item.unit_cost) ORDER BY item.id) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), '[]'::json) AS items FROM supply_purchase_orders purchase_order ORDER BY purchase_order.created_at DESC, purchase_order.id DESC; `); return result.rows.map(mapPurchaseOrder); }; const createPurchaseOrder = async (payload = {}) => { const supplier = normalizeText(payload.supplier); const expectedDate = /^\d{4}-\d{2}-\d{2}$/.test(normalizeText(payload.expectedDate)) ? normalizeText(payload.expectedDate) : null; const notes = normalizeText(payload.notes); const items = (Array.isArray(payload.items) ? payload.items : []).map(item => ({ material: normalizeText(item?.material), quantity: normalizeNumber(item?.quantity), unit: normalizeText(item?.unit) || 'kg', unitCost: normalizeNonNegativeNumber(item?.unitCost) })).filter(item => item.material && item.quantity); if (!supplier) throw createValidationError('Fornecedor é obrigatório para a ordem de compra.'); if (!items.length) throw createValidationError('Inclua pelo menos um material na ordem de compra.'); const client = await pool.connect(); try { await client.query('BEGIN'); const orderResult = await client.query(`INSERT INTO supply_purchase_orders (supplier, status, expected_date, notes, updated_at) VALUES ($1, 'draft', $2, NULLIF($3, ''), CURRENT_TIMESTAMP) RETURNING id;`, [supplier, expectedDate, notes]); const id = orderResult.rows[0].id; await client.query(`UPDATE supply_purchase_orders SET number = $1 WHERE id = $2;`, [`OC-${String(id).padStart(5, '0')}`, id]); for (const item of items) { await client.query(`INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit, unit_cost) VALUES ($1, $2, $3, $4, $5);`, [id, item.material, item.quantity, item.unit, item.unitCost]); } await client.query('COMMIT'); return (await listPurchaseOrders()).find(order => order.id === id); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } }; const updatePurchaseOrderStatus = async (id, status) => { const normalizedStatus = normalizeText(status).toLowerCase(); if (!PURCHASE_ORDER_STATUSES.has(normalizedStatus)) throw createValidationError('Status de ordem de compra inválido.'); const result = await pool.query(`UPDATE supply_purchase_orders SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id;`, [normalizedStatus, id]); if (!result.rowCount) { const error = new Error('Ordem de compra não encontrada.'); error.statusCode = 404; throw error; } return (await listPurchaseOrders()).find(order => order.id === Number(id)); }; const saveMaterialDetails = async (materialKey, payload) => { const normalizedKey = normalizeKey(materialKey); if (!normalizedKey) throw createValidationError('Material inválido para edição.'); const result = await pool.query(` INSERT INTO supply_material_details ( material_key, display_name, preferred_supplier, notes, updated_at ) VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) ON CONFLICT (material_key) DO UPDATE SET display_name = EXCLUDED.display_name, preferred_supplier = EXCLUDED.preferred_supplier, notes = EXCLUDED.notes, updated_at = CURRENT_TIMESTAMP RETURNING material_key, display_name, preferred_supplier, notes, created_at, updated_at `, [ normalizedKey, normalizeText(payload.displayName) || null, normalizeText(payload.preferredSupplier) || null, normalizeText(payload.notes) || null ]); return mapMaterialDetails(result.rows[0]); }; 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, materialKey: normalizeKey(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 += Number(lot.availableQuantity ?? 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 stockKg = getAvailablePlanningStock(need.stockKg); const purchaseKg = Math.max(need.plannedKg - stockKg - need.pendingKg, 0); let status = 'ok'; if (purchaseKg > 0 && (need.priority === 'Crítico' || stockKg === 0)) status = 'critical'; else if (purchaseKg > 0) status = 'attention'; return { material: need.material, materialKey: need.materialKey, plannedKg: need.plannedKg, 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(graphs.balance, stock.saldo, 0)::numeric AS stock_quantity FROM period_orders FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.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, r.consumption_quantity, r.consumption_unit, r.source 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; }; // The Tiny structure is the source of truth when it exists. Consumption // references remain useful as a fallback for legacy/manual products, but they // must not make an imported structure invisible to purchase planning. const listCompositionRows = async () => { const result = await pool.query(` SELECT composition.finished_tiny_product_id, composition.finished_product_sku, component.component_tiny_id, component.component_sku, component.component_name, component.quantity_per_unit, component.unit FROM product_compositions composition JOIN product_composition_components component ON component.product_composition_id = composition.id WHERE composition.source IN ('tiny_olist_v3', 'manual') AND NOT EXISTS ( SELECT 1 FROM product_compositions manual_composition WHERE manual_composition.source = 'manual' AND composition.source = 'tiny_olist_v3' AND ( manual_composition.finished_tiny_product_id = composition.finished_tiny_product_id OR ( COALESCE(manual_composition.finished_tiny_product_id, '') = '' AND manual_composition.finished_product_sku = composition.finished_product_sku ) ) ) ORDER BY composition.id, component.id; `); return result.rows; }; const listTinyStockRows = async () => { const result = await pool.query(` SELECT stock.produto_id, stock.nome, COALESCE(stock.saldo, 0)::numeric AS tiny_saldo, graphs.balance::numeric AS graphs_balance, COALESCE(graphs.balance, stock.saldo, 0)::numeric AS saldo FROM stock LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id WHERE COALESCE(stock.produto_id, '') <> '' OR COALESCE(stock.nome, '') <> ''; `); return result.rows; }; const mergeNeedLine = (needsByMaterial, key, patch) => { const current = needsByMaterial.get(key) || { material: patch.material, materialKey: patch.materialKey || normalizeKey(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, compositionRows, tinyStockRows] = await Promise.all([ listProjectDemandRows(), listConsumptionReferenceRows(), listCompositionRows(), listTinyStockRows() ]); const referencesBySku = referenceRows.reduce((references, reference) => { const sku = normalizeSku(reference.product_sku); if (!sku) return references; references.set(sku, [...(references.get(sku) || []), reference]); return references; }, new Map()); const needsByMaterial = new Map(); const compositionsByProduct = compositionRows.reduce((compositions, component) => { [component.finished_tiny_product_id, component.finished_product_sku] .map(normalizeSku) .filter(Boolean) .forEach(identity => { compositions.set(identity, [...(compositions.get(identity) || []), component]); }); return compositions; }, new Map()); // A need is keyed by the imported Tiny component identity whenever // possible. Names are retained for display and for matching manual lots. const componentNeedKeys = new Map(); const registerNeedKey = (identity, key) => { const normalizedIdentity = normalizeSku(identity); if (normalizedIdentity) componentNeedKeys.set(normalizedIdentity, key); }; const addCompositionNeed = (component, row, productId, suggestedQuantity, quantitySold, stockQuantity) => { const unit = normalizeUnit(component.unit); const material = normalizeText(component.component_name) || normalizeText(component.component_sku) || normalizeText(component.component_tiny_id) || 'Material sem cadastro'; const quantityPerUnit = Number(component.quantity_per_unit || 0); const tinyId = normalizeText(component.component_tiny_id); const sku = normalizeSku(component.component_sku); const identity = tinyId || sku || normalizeKey(material); const key = `${unit}:${normalizeSku(identity) || normalizeKey(material)}`; if (!Number.isFinite(quantityPerUnit) || quantityPerUnit <= 0) { mergeNeedLine(needsByMaterial, `missing-quantity:${productId}:${identity}`, { material: `Revisar quantidade: ${normalizeText(row.product_name) || productId}`, plannedKg: suggestedQuantity, priority: 'Crítico', unit: 'un.', source: 'tiny_composition', missingReference: true, product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity } }); return; } mergeNeedLine(needsByMaterial, key, { material, materialKey: normalizeKey(material), plannedKg: suggestedQuantity * quantityPerUnit, priority: 'Atenção', unit, source: 'tiny_composition', product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity, consumptionQuantity: quantityPerUnit, consumptionUnit: unit } }); registerNeedKey(tinyId, key); registerNeedKey(sku, key); // Lots are normally registered by material name, not Tiny ID. registerNeedKey(material, key); }; 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 composition = compositionsByProduct.get(productId) || []; if (composition.length) { composition.forEach(component => addCompositionNeed(component, row, productId, suggestedQuantity, quantitySold, stockQuantity)); return; } const references = referencesBySku.get(productId) || []; if (!references.length) { 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; } references.forEach(reference => { const consumptionQuantity = Number(reference.consumption_quantity || 0); const consumptionUnit = normalizeUnit(reference.consumption_unit); const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro'; if (consumptionQuantity > 0) { mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, { material: materialName, plannedKg: suggestedQuantity * consumptionQuantity, priority: 'Atenção', unit: consumptionUnit, source: reference.source || 'project_demand', color: reference.color, product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity, consumptionQuantity, consumptionUnit } }); return; } const yieldPerKg = getReferenceYield(reference); if (!yieldPerKg) { mergeNeedLine(needsByMaterial, `missing-yield:${productId}:${reference.material_product_id || 'material'}`, { 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; } mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, { material: materialName, plannedKg: suggestedQuantity / yieldPerKg, priority: 'Atenção', unit: 'kg', source: reference.source || 'project_demand', color: reference.color, product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity, yieldPerKg } }); }); }); const getNeedForInventory = (unit, id, name) => { const normalizedUnit = normalizeUnit(unit); const key = componentNeedKeys.get(normalizeSku(id)) || componentNeedKeys.get(normalizeSku(name)); return key && key.startsWith(`${normalizedUnit}:`) ? needsByMaterial.get(key) : undefined; }; // Tiny saldo is material stock too. It is matched using the component ID // first, then SKU/name, which lets "MALHA PRETA" stock directly reduce the // material purchase suggestion imported from the product structure. tinyStockRows.forEach(stock => { const need = getNeedForInventory('un.', stock.produto_id, stock.nome) || getNeedForInventory('kg', stock.produto_id, stock.nome) || getNeedForInventory('', stock.produto_id, stock.nome); if (need) need.stockKg += Number(stock.saldo || 0); }); lots.forEach(lot => { const need = getNeedForInventory(lot.unit, '', lot.product) || needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`); if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity); }); receipts.forEach(receipt => { if (receipt.status !== 'pending') return; const need = getNeedForInventory(receipt.unit, '', receipt.product) || needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`); if (need) need.pendingKg += receipt.quantity; }); return Array.from(needsByMaterial.values()).map(need => { const stockKg = getAvailablePlanningStock(need.stockKg); const purchaseKg = Math.max(need.plannedKg - stockKg - need.pendingKg, 0); let status = 'ok'; if (purchaseKg > 0 && (need.priority === 'Crítico' || stockKg === 0)) status = 'critical'; else if (purchaseKg > 0) status = 'attention'; return { material: need.material, materialKey: need.materialKey, plannedKg: need.plannedKg, 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; // Stock/receipts are the same physical inventory for a manual plan and // an imported-composition need. They are alternatives views of the // balance, never amounts to add together. current.stockKg = Math.max(current.stockKg, need.stockKg); current.pendingKg = Math.max(current.pendingKg, need.pendingKg); 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 => { const stockKg = getAvailablePlanningStock(need.stockKg); const purchaseKg = Math.max(need.plannedKg - stockKg - need.pendingKg, 0); return { ...need, stockKg, purchaseKg, status: purchaseKg > 0 ? (need.priority === 'Crítico' || stockKg === 0 ? 'critical' : 'attention') : 'ok', 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 applyMaterialDetails = (purchaseNeeds, materialDetails) => { const detailsByKey = new Map(materialDetails.map(detail => [detail.materialKey, detail])); return purchaseNeeds.map(need => { const detail = detailsByKey.get(normalizeKey(need.materialKey || need.material)); if (!detail) return { ...need, preferredSupplier: '', notes: '' }; return { ...need, material: detail.displayName || need.material, suppliers: detail.preferredSupplier ? [detail.preferredSupplier, ...need.suppliers.filter(supplier => supplier !== detail.preferredSupplier)] : need.suppliers, preferredSupplier: detail.preferredSupplier, notes: detail.notes }; }); }; 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, materialDetails] = await Promise.all([ listReceipts(), listLots(), listMovements(), listFabricPlans(), listMaterialDetails() ]); const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts); const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts); const purchaseNeeds = applyMaterialDetails( mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds), materialDetails ); return { receipts, lots, movements, fabricPlans, purchaseNeeds, stats: buildStats(receipts, lots, purchaseNeeds) }; }; const listPurchaseNeeds = async () => { const [plans, lots, receipts, materialDetails] = await Promise.all([ listFabricPlans(), listLots(), listReceipts(), listMaterialDetails() ]); return applyMaterialDetails( mergePurchaseNeeds( buildPurchaseNeeds(plans, lots, receipts), await buildProjectPurchaseNeeds(lots, receipts) ), materialDetails ); }; 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 TRANSFORMATION_STAGES = new Set(['knitting', 'dyeing', 'finishing']); const TRANSFORMATION_STATUSES = new Set(['planned', 'in_progress', 'completed', 'canceled']); const createMaterialTransformation = async (payload = {}) => { const stage = normalizeText(payload.stage).toLowerCase(); const inputMaterial = normalizeText(payload.inputMaterial); const outputMaterial = normalizeText(payload.outputMaterial); const inputQuantityKg = normalizeNumber(payload.inputQuantityKg); const expectedLossPercent = normalizeNonNegativeNumber(payload.expectedLossPercent) ?? 0; if (!TRANSFORMATION_STAGES.has(stage)) throw createValidationError('Etapa de transformação inválida.'); if (!inputMaterial || !outputMaterial) throw createValidationError('Material de entrada e saída são obrigatórios.'); if (!inputQuantityKg) throw createValidationError('Quantidade de entrada deve ser maior que zero.'); if (expectedLossPercent >= 100) throw createValidationError('A perda esperada deve ser menor que 100%.'); const expectedOutputKg = Number((inputQuantityKg * (1 - (expectedLossPercent / 100))).toFixed(4)); const result = await pool.query(` INSERT INTO supply_material_transformations ( batch_code, stage, input_material, input_quantity_kg, output_material, output_color, expected_loss_percent, expected_output_kg, supplier, priority, status, notes, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'planned', $11, CURRENT_TIMESTAMP) RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color, expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at `, [ normalizeText(payload.batchCode) || null, stage, inputMaterial, inputQuantityKg, outputMaterial, normalizeText(payload.outputColor) || null, expectedLossPercent, expectedOutputKg, normalizeText(payload.supplier) || null, normalizeText(payload.priority) || 'Normal', normalizeText(payload.notes) || null ]); return mapMaterialTransformation(result.rows[0]); }; const updateMaterialTransformationStatus = async (id, status) => { const normalizedStatus = normalizeText(status).toLowerCase(); if (!TRANSFORMATION_STATUSES.has(normalizedStatus)) throw createValidationError('Status da transformação inválido.'); const result = await pool.query(` UPDATE supply_material_transformations SET status = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color, expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at `, [id, normalizedStatus]); if (!result.rows[0]) throw createValidationError('Transformação não encontrada.'); return mapMaterialTransformation(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 getActiveReservationQuantity = async (client, lotId) => { const result = await client.query(` SELECT quantity, consumed_quantity FROM cut_plan_lot_reservations WHERE supply_lot_id = $1 AND status = 'active' FOR UPDATE; `, [lotId]); return result.rows.reduce((total, reservation) => ( total + Number(reservation.quantity || 0) - Number(reservation.consumed_quantity || 0) ), 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 reservedQuantity = await getActiveReservationQuantity(client, lot.id); if (countedQuantity < reservedQuantity) { throw createValidationError(`A contagem não pode ficar abaixo de ${reservedQuantity} ${lot.unit} já reservados para corte.`); } 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.'); let reference = ''; if (productionOrderNumber) { const orderResult = await client.query(` SELECT order_reference FROM production_orders WHERE number = $1 LIMIT 1; `, [productionOrderNumber]); reference = normalizeText(orderResult.rows[0]?.order_reference); } const allReservationsResult = await client.query(` SELECT id, cut_plan_reference, quantity, consumed_quantity FROM cut_plan_lot_reservations WHERE supply_lot_id = $1 AND status = 'active' ORDER BY created_at, id FOR UPDATE; `, [lot.id]); const totalReserved = allReservationsResult.rows.reduce((total, reservation) => ( total + Number(reservation.quantity) - Number(reservation.consumed_quantity) ), 0); const reservedByThisPlan = allReservationsResult.rows .filter(reservation => reference && reservation.cut_plan_reference === reference) .reduce((total, reservation) => total + Number(reservation.quantity) - Number(reservation.consumed_quantity), 0); const permittedQuantity = Math.max(0, currentQuantity - totalReserved + reservedByThisPlan); if (quantity > permittedQuantity) { throw createValidationError(`O lote possui ${Math.max(0, currentQuantity - totalReserved)} ${lot.unit} livres e ${reservedByThisPlan} ${lot.unit} reservados para esta OP.`); } 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' ]); if (productionOrderNumber) { if (reference) { const reservationsResult = await client.query(` SELECT id, quantity, consumed_quantity FROM cut_plan_lot_reservations WHERE supply_lot_id = $1 AND cut_plan_reference = $2 AND status = 'active' ORDER BY created_at, id FOR UPDATE; `, [lot.id, reference]); let remainingToApply = quantity; for (const reservation of reservationsResult.rows) { if (remainingToApply <= 0) break; const reservedRemaining = Number(reservation.quantity) - Number(reservation.consumed_quantity); const consumed = Math.min(reservedRemaining, remainingToApply); if (consumed <= 0) continue; const nextConsumedQuantity = Number(reservation.consumed_quantity) + consumed; await client.query(` UPDATE cut_plan_lot_reservations SET consumed_quantity = $1, status = CASE WHEN $1 >= quantity THEN 'consumed' ELSE 'active' END, updated_at = CURRENT_TIMESTAMP WHERE id = $2; `, [nextConsumedQuantity, reservation.id]); remainingToApply -= consumed; } } } await client.query('COMMIT'); return updatedLot; } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } }; module.exports = { adjustInventoryLot, approveReceipt, consumeLotForProduction, createFabricPlan, createMaterialTransformation, createPurchaseOrder, createReceipt, deleteFabricPlan, deleteReceipt, getSupplySummary, buildProjectPurchaseNeeds, buildPurchaseNeeds, listFabricPlans, listMaterialTransformations, listMaterialDetails, listKnownSuppliers, listPurchaseOrders, listLots, listMovements, listPurchaseNeeds, listReceipts, saveMaterialDetails, updateMaterialTransformationStatus, updatePurchaseOrderStatus };