Files
graphs/backend/services/dataHealthService.js
Cauê Faleiros e993bfdaf3
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
Use Tiny compositions for purchase planning
2026-08-03 10:22:39 -03:00

86 lines
5.3 KiB
JavaScript

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