Files
graphs/backend/services/supplyService.js
2026-08-27 10:44:49 -03:00

1341 lines
53 KiB
JavaScript

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
};