Connect supplies to project demand

This commit is contained in:
Cauê Faleiros
2026-07-13 15:17:33 -03:00
parent 8f3f2f3e93
commit 060b4da907
5 changed files with 334 additions and 17 deletions

View File

@@ -1,5 +1,8 @@
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) => {
@@ -19,6 +22,8 @@ const normalizeKey = (value) => normalizeText(value)
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
const normalizeSku = (value) => normalizeText(value).toUpperCase();
const mapReceipt = (row) => ({
id: row.id,
category: row.category,
@@ -187,6 +192,268 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
});
};
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
@@ -209,7 +476,9 @@ const getSupplySummary = async () => {
listMovements(),
listFabricPlans()
]);
const purchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts);
const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds);
return {
receipts,
@@ -228,7 +497,10 @@ const listPurchaseNeeds = async () => {
listReceipts()
]);
return buildPurchaseNeeds(plans, lots, receipts);
return mergePurchaseNeeds(
buildPurchaseNeeds(plans, lots, receipts),
await buildProjectPurchaseNeeds(lots, receipts)
);
};
const createReceipt = async (payload) => {
@@ -513,6 +785,8 @@ module.exports = {
deleteFabricPlan,
deleteReceipt,
getSupplySummary,
buildProjectPurchaseNeeds,
buildPurchaseNeeds,
listFabricPlans,
listLots,
listMovements,