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

@@ -11,6 +11,7 @@ const {
getRfmAnalytics
} = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const { getDataHealthSummary } = require('../services/dataHealthService');
const router = express.Router();
@@ -78,6 +79,15 @@ router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
}
});
router.get('/analytics/data-health', verifyToken, async (req, res) => {
try {
res.json(await getDataHealthSummary());
} catch (error) {
console.error('Error fetching data health:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => {
try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));

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

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