Use Tiny compositions for purchase planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
This commit is contained in:
85
backend/services/dataHealthService.js
Normal file
85
backend/services/dataHealthService.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
||||
const isRawOrService = (value) => /\b(?:MALHA|RIBANA|FIO|TECIDO|SERVICO|SERVIÇO|TINTURARIA|TECELAGEM|FRETE)\b/i.test(value);
|
||||
|
||||
const getDataHealthSummary = async () => {
|
||||
const [productsResult, compositionsResult, componentsResult, stockResult] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT produto_id AS id, MAX(NULLIF(produto_descricao, '')) AS name FROM orders GROUP BY produto_id
|
||||
UNION
|
||||
SELECT produto_id AS id, MAX(NULLIF(nome, '')) AS name FROM stock GROUP BY produto_id;
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT id, finished_tiny_product_id, finished_product_sku, finished_product_description, finished_product_unit
|
||||
FROM product_compositions WHERE source = 'tiny_olist_v3';
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT product_composition_id, component_tiny_id, component_sku, component_name, quantity_per_unit, unit
|
||||
FROM product_composition_components;
|
||||
`),
|
||||
pool.query(`SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo FROM stock;`)
|
||||
]);
|
||||
|
||||
const products = Array.from(new Map(productsResult.rows.map(row => [normalizeSku(row.id), row])).values());
|
||||
const compositions = compositionsResult.rows;
|
||||
const components = componentsResult.rows;
|
||||
const stock = stockResult.rows;
|
||||
const stockKeys = new Set(stock.flatMap(item => [normalizeSku(item.produto_id), normalizeSku(item.nome)]).filter(Boolean));
|
||||
const compositionKeys = new Set(compositions.flatMap(item => [normalizeSku(item.finished_tiny_product_id), normalizeSku(item.finished_product_sku)]).filter(Boolean));
|
||||
const productKeys = new Set(products.map(item => normalizeSku(item.id)).filter(Boolean));
|
||||
const linkedComponentKeys = new Set([...stockKeys, ...productKeys]);
|
||||
const materialKeys = new Set(components.map(item => normalizeSku(item.component_tiny_id) || normalizeSku(item.component_sku) || normalizeSku(item.component_name)).filter(Boolean));
|
||||
const materialStockKeys = new Set([...materialKeys].filter(key => stockKeys.has(key)));
|
||||
const rowsByComposition = new Map(compositions.map(item => [Number(item.id), item]));
|
||||
const issues = [];
|
||||
const addIssue = (type, severity, title, detail, productSku = '', component = '') => {
|
||||
issues.push({ type, severity, title, detail, productSku, component });
|
||||
};
|
||||
|
||||
compositions.filter(row => !normalizeText(row.finished_product_sku)).forEach(row => {
|
||||
addIssue('missing_product_sku', 'critical', 'Produto sem SKU', normalizeText(row.finished_product_description) || 'Composição sem descrição', '', '');
|
||||
});
|
||||
components.filter(row => {
|
||||
const key = normalizeSku(row.component_tiny_id) || normalizeSku(row.component_sku) || normalizeSku(row.component_name);
|
||||
return !key || !linkedComponentKeys.has(key);
|
||||
}).forEach(row => {
|
||||
const parent = rowsByComposition.get(Number(row.product_composition_id));
|
||||
addIssue('component_without_stock_link', 'attention', 'Componente sem vínculo de estoque', normalizeText(row.component_name) || 'Componente sem nome', normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id));
|
||||
});
|
||||
components.filter(row => {
|
||||
const quantity = Number(row.quantity_per_unit || 0);
|
||||
return !Number.isFinite(quantity) || quantity <= 0 || quantity > 100 || !normalizeText(row.unit);
|
||||
}).forEach(row => {
|
||||
const parent = rowsByComposition.get(Number(row.product_composition_id));
|
||||
addIssue('suspicious_quantity', 'attention', 'Quantidade suspeita', `${normalizeText(row.component_name)} · ${row.quantity_per_unit || 0} ${normalizeText(row.unit) || '(sem unidade)'}`, normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id));
|
||||
});
|
||||
compositions.filter(row => isRawOrService(row.finished_product_description) || normalizeText(row.finished_product_unit).toLowerCase() !== 'un').forEach(row => {
|
||||
addIssue('raw_or_service_structure', 'info', 'Estrutura de matéria-prima/serviço', normalizeText(row.finished_product_description), normalizeText(row.finished_product_sku), '');
|
||||
});
|
||||
products.filter(row => !compositionKeys.has(normalizeSku(row.id))).forEach(row => {
|
||||
addIssue('product_without_composition', 'attention', 'Produto sem composição', normalizeText(row.name) || normalizeText(row.id), normalizeText(row.id), '');
|
||||
});
|
||||
|
||||
const productsWithComposition = products.filter(row => compositionKeys.has(normalizeSku(row.id))).length;
|
||||
const productsWithStockLink = products.filter(row => stockKeys.has(normalizeSku(row.id))).length;
|
||||
|
||||
return {
|
||||
totals: {
|
||||
products: productKeys.size,
|
||||
productsWithComposition,
|
||||
productsWithStockLink,
|
||||
materials: materialKeys.size,
|
||||
materialsWithStock: materialStockKeys.size,
|
||||
compositions: compositions.length,
|
||||
components: components.length
|
||||
},
|
||||
issues: issues.sort((a, b) => {
|
||||
const severityOrder = { critical: 0, attention: 1, info: 2 };
|
||||
return severityOrder[a.severity] - severityOrder[b.severity] || a.title.localeCompare(b.title);
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { getDataHealthSummary };
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user