Use Tiny compositions for purchase planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s

This commit is contained in:
Cauê Faleiros
2026-08-03 10:22:39 -03:00
parent 1a3fed3dda
commit e993bfdaf3
11 changed files with 544 additions and 40 deletions

View File

@@ -269,6 +269,37 @@ const listConsumptionReferenceRows = async () => {
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 = 'tiny_olist_v3'
ORDER BY composition.id, component.id;
`);
return result.rows;
};
const listTinyStockRows = async () => {
const result = await pool.query(`
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo
FROM stock
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> '';
`);
return result.rows;
};
const mergeNeedLine = (needsByMaterial, key, patch) => {
const current = needsByMaterial.get(key) || {
material: patch.material,
@@ -302,9 +333,11 @@ const mergeNeedLine = (needsByMaterial, key, patch) => {
};
const buildProjectPurchaseNeeds = async (lots, receipts) => {
const [demandRows, referenceRows] = await Promise.all([
const [demandRows, referenceRows, compositionRows, tinyStockRows] = await Promise.all([
listProjectDemandRows(),
listConsumptionReferenceRows()
listConsumptionReferenceRows(),
listCompositionRows(),
listTinyStockRows()
]);
const referencesBySku = referenceRows.reduce((references, reference) => {
const sku = normalizeSku(reference.product_sku);
@@ -314,6 +347,70 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
}, 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,
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;
@@ -324,6 +421,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
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}`, {
@@ -410,14 +513,32 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
});
});
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 = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
const need = getNeedForInventory(lot.unit, '', lot.product)
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
if (need) need.stockKg += lot.quantity;
});
receipts.forEach(receipt => {
if (receipt.status !== 'pending') return;
const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
const need = getNeedForInventory(receipt.unit, '', receipt.product)
|| needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
if (need) need.pendingKg += receipt.quantity;
});
@@ -462,9 +583,11 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
}
current.plannedKg += need.plannedKg;
current.stockKg += need.stockKg;
current.pendingKg += need.pendingKg;
current.purchaseKg += need.purchaseKg;
// 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'
@@ -483,11 +606,18 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
});
return Array.from(mergedByKey.values())
.map(need => ({
...need,
suppliers: Array.from(need.suppliers),
colors: Array.from(need.colors)
}))
.map(need => {
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
return {
...need,
purchaseKg,
status: purchaseKg > 0
? (need.priority === 'Crítico' || need.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);