Connect supplies to project demand
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user