/* === synthetic-data.jsx ============================================ F2 — Synthetic Data Engine (Prioridade #1 · Demo Interativa Real). Gera um dataset sintético plausível e INTERNAMENTE CONSISTENTE a partir do perfil do prospect (cosmetic + data + vars do state). 100% client-side, sem backend. Determinístico: a mesma demo (slug/ empresa) sempre gera os mesmos dados — reabrir o link é idempotente. Consistência garantida por construção: Σ spend fornecedor = Σ spend categoria = Σ spend filial = Σ spend mês = totalSpend Σ saving (todas as dimensões) = roiValor (quando o anchor existe) Exposto em window.SyntheticData. NÃO é persistido no state nem no hash — é regenerado sob demanda e cacheado em memória pelo perfil. ===================================================================== */ (function () { /* ---------- PRNG determinístico (mulberry32 + FNV-1a) ---------- */ function hashString(str) { let h = 2166136261 >>> 0; const s = String(str || ''); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function mulberry32(a) { return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)]; const randInt = (rng, lo, hi) => lo + Math.floor(rng() * (hi - lo + 1)); /* ---------- Parse de valores BRL abreviados ---------- Aceita "417,1 Mi", "5,54Mi", "R$ 2,4 M", "1.840", "12 bilhões". */ function parseBRLNumber(str) { if (typeof str === 'number') return str; if (!str) return null; const s = String(str).trim().toLowerCase().replace(/r\$/g, '').trim(); /* captura número + sufixo de escala (colado ou separado): "5,54M", "100 mi", "417,1 Mi", "12 mil", "2,4 bilhões", "1.840". A ordem da alternância importa: milhão antes de mil antes de mi/m. */ const m = s.match(/(-?[\d.]+(?:,\d+)?)\s*(bilh\w*|bi|milh\w*|mil|mm|mi|m|b|k)?/); if (!m) return null; const num = parseFloat(m[1].replace(/\./g, '').replace(',', '.')); if (isNaN(num)) return null; const unit = m[2] || ''; let mult = 1; if (/^(bilh|bi|b)/.test(unit)) mult = 1e9; else if (/^(milh|mm|mi|m)$/.test(unit) || /^milh/.test(unit)) mult = 1e6; else if (/^(mil|k)$/.test(unit)) mult = 1e3; return num * mult; } /* ---------- Formatação compacta pt-BR ---------- */ function fmtBRL(n, prefix) { const p = prefix === false ? '' : 'R$ '; const abs = Math.abs(n); /* arredonda e remove só os zeros à direita da parte decimal (nunca os inteiros) */ const fix = (v) => { let str = v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2); if (str.indexOf('.') >= 0) str = str.replace(/0+$/, '').replace(/\.$/, ''); return str.replace('.', ','); }; if (abs >= 1e9) return p + fix(n / 1e9) + ' Bi'; if (abs >= 1e6) return p + fix(n / 1e6) + ' Mi'; if (abs >= 1e3) return p + fix(n / 1e3) + ' mil'; return p + Math.round(n).toLocaleString('pt-BR'); } const fmtInt = (n) => Math.round(n).toLocaleString('pt-BR'); const fmtPct = (n) => (n * 100).toFixed(1).replace('.', ',') + '%'; /* ---------- CNPJ sintético com dígitos verificadores válidos ---------- */ function cnpj(rng) { const base = []; for (let i = 0; i < 8; i++) base.push(randInt(rng, 0, 9)); base.push(0, 0, 0, 1); /* filial 0001 */ const calc = (nums, weights) => { const sum = nums.reduce((s, d, i) => s + d * weights[i], 0); const r = sum % 11; return r < 2 ? 0 : 11 - r; }; const w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; const w2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; const d1 = calc(base, w1); const d2 = calc([...base, d1], w2); const all = [...base, d1, d2].join(''); return `${all.slice(0, 2)}.${all.slice(2, 5)}.${all.slice(5, 8)}/${all.slice(8, 12)}-${all.slice(12)}`; } /* ---------- Pools de nomes plausíveis por setor ---------- */ const NAME_POOLS = { alimentos: { sup: ['Bunge', 'Cargill', 'Klabin Embalagens', 'Trombini', 'Bemis Latam', 'Tetra Pak', 'Suzano Papel', 'Ipiranga Logística', 'Friozem Armazéns', 'BRF Insumos', 'Yara Fertilizantes', 'Seara Alimentos'], cat: ['Embalagens', 'Matéria-prima — grãos', 'Insumos químicos', 'Logística & frete', 'Manutenção industrial', 'EPI & segurança', 'Energia & utilidades', 'Serviços terceirizados', 'TI & telecom', 'Marketing & trade', 'Frota & combustível', 'Limpeza & higienização'], }, manufatura: { sup: ['Wieland do Brasil', 'Saint-Gobain', 'Tigre S.A.', 'Aperam Inox', 'Mahle Metal Leve', 'Gerdau Aços', 'Votorantim Metais', 'WEG Equipamentos', 'Bosch Rexroth', 'SKF Rolamentos', 'Atlas Copco', 'Tupy Fundições'], cat: ['Metais & ligas', 'Componentes usinados', 'Manutenção (MRO)', 'Logística & frete', 'Energia & utilidades', 'Ferramentaria', 'EPI & segurança', 'Serviços de engenharia', 'TI & automação', 'Embalagens', 'Frota & combustível', 'Químicos industriais'], }, varejo: { sup: ['Procter & Gamble', 'Unilever Brasil', 'Nestlé', 'Ambev', 'JBS', 'Coca-Cola FEMSA', 'Mondelez', 'Bunge Alimentos', 'Reckitt', 'Colgate-Palmolive', 'Pepsico', 'BRF'], cat: ['Mercadoria para revenda', 'Logística & distribuição', 'Embalagens', 'Marketing & trade', 'Manutenção de lojas', 'TI & e-commerce', 'Serviços terceirizados', 'Energia & utilidades', 'EPI & uniformes', 'Frota & combustível', 'Limpeza & facilities', 'Material de escritório'], }, saude: { sup: ['Johnson & Johnson Medical', 'Medtronic', 'Cremer', 'Baxter', 'Fresenius', 'B. Braun', 'GE Healthcare', 'Philips Medical', 'Cristália', 'Eurofarma Hospitalar', 'BD Becton Dickinson', 'Hospitalar Distribuição'], cat: ['OPME (órteses & próteses)', 'Medicamentos', 'Material médico-hospitalar', 'Equipamentos médicos', 'Serviços terceirizados', 'Manutenção predial & clínica', 'TI & sistemas', 'Limpeza & higienização', 'Lavanderia', 'Alimentação & nutrição', 'Energia & utilidades', 'EPI & descartáveis'], }, default: { sup: ['Fornecedor Alfa', 'Fornecedor Beta', 'Fornecedor Gama', 'Fornecedor Delta', 'Fornecedor Épsilon', 'Fornecedor Zeta', 'Fornecedor Eta', 'Fornecedor Teta', 'Fornecedor Iota', 'Fornecedor Capa', 'Fornecedor Lambda', 'Fornecedor Sigma'], cat: ['Materiais diretos', 'Materiais indiretos', 'Serviços', 'Logística & frete', 'Manutenção (MRO)', 'TI & telecom', 'Energia & utilidades', 'Marketing', 'EPI & segurança', 'Frota & combustível', 'Facilities', 'Material de escritório'], }, }; function sectorKey(industry) { const s = String(industry || '').toLowerCase(); if (/aliment|bebida|food|agro/.test(s)) return 'alimentos'; if (/saúde|saude|hospital|operadora|clínic|clinic|health/.test(s)) return 'saude'; if (/varejo|retail|consumo|atacad|distribu/.test(s)) return 'varejo'; if (/manufat|indúst|indust|metal|químic|quimic|automotiv|bens/.test(s)) return 'manufatura'; return 'default'; } /* sufixos para alongar pools sem repetir nomes */ const SUFFIXES = ['Brasil', 'Indústria', 'Comércio', 'Latam', 'do Sul', 'Nordeste', 'Distribuição', 'Logística', 'Group', 'S.A.', 'Ltda', 'Serviços', 'Engenharia', 'Suprimentos']; /* ---------- Perfil derivado do state ---------- */ function profileFromState(state) { const cosmetic = state.cosmetic || {}; const data = state.data || {}; const vars = state.vars || {}; const company = (vars.empresa && String(vars.empresa).trim()) || cosmetic.company || 'Prospect'; const sector = sectorKey(cosmetic.industry || vars.setor); const currency = data.currency || 'BRL'; const totalSpend = parseBRLNumber(vars.spend_total) || parseBRLNumber(vars.spend_p2p_enderecavel) || defaultSpendBySector(sector); const roiValor = parseBRLNumber(vars.roi_valor) || null; const seedKey = `${company}|${sector}|${cosmetic.industry || ''}`; return { company, sector, currency, totalSpend, roiValor, seedKey, sitesSeed: (data.sites && data.sites.length ? data.sites : ['Matriz']).slice(0, 8), suppliersSeed: data.suppliers || [], categoriesSeed: data.skuCategories || [], }; } function defaultSpendBySector(sector) { return { saude: 320e6, varejo: 540e6, manufatura: 260e6, alimentos: 410e6, default: 200e6 }[sector] || 200e6; } /* ---------- Distribuição Pareto (curva ABC) ---------- */ function paretoWeights(rng, n) { /* peso ~ 1/rank^1.1 com ruído → concentração ~80/20 */ const w = []; for (let i = 0; i < n; i++) { w.push((1 / Math.pow(i + 1, 1.08)) * (0.75 + rng() * 0.5)); } const sum = w.reduce((s, x) => s + x, 0); return w.map(x => x / sum); } /* aloca `total` em n parcelas inteiras que somam EXATAMENTE total */ function allocateExact(weights, total) { const raw = weights.map(w => w * total); const floored = raw.map(Math.floor); let rem = Math.round(total - floored.reduce((s, x) => s + x, 0)); /* distribui o resto pelas maiores frações */ const order = raw.map((v, i) => ({ i, frac: v - Math.floor(v) })) .sort((a, b) => b.frac - a.frac); for (let k = 0; k < order.length && rem > 0; k++) { floored[order[k].i]++; rem--; } return floored; } /* ---------- Geração principal ---------- */ function generateDataset(profile) { const rng = mulberry32(hashString(profile.seedKey)); const pool = NAME_POOLS[profile.sector] || NAME_POOLS.default; const total = profile.totalSpend; /* --- Categorias (~12, curva ABC) --- */ const catNames = uniqueNames(pool.cat, profile.categoriesSeed, 12, rng); const catW = paretoWeights(rng, catNames.length); const catSpend = allocateExact(catW, total); const categories = catNames.map((name, i) => { const share = catSpend[i] / total; const klass = share > 0.12 ? 'A' : share > 0.045 ? 'B' : 'C'; return { id: 'cat' + i, name, spend: catSpend[i], share, class: klass, skuCount: randInt(rng, klass === 'A' ? 80 : 20, klass === 'A' ? 600 : 180), }; }); /* --- Filiais --- */ const siteNames = profile.sitesSeed.length ? profile.sitesSeed : ['Matriz']; const siteW = paretoWeights(rng, siteNames.length); const siteSpend = allocateExact(siteW, total); const sites = siteNames.map((name, i) => ({ id: 'site' + i, name, spend: siteSpend[i], share: siteSpend[i] / total })); /* --- Fornecedores (~120, Pareto; cada um numa categoria) --- */ const N_SUP = 120; const supNames = uniqueNames(pool.sup, profile.suppliersSeed, N_SUP, rng); const supW = paretoWeights(rng, N_SUP); const supSpend = allocateExact(supW, total); /* atribui categoria ponderada pelo spend da categoria → Σ supplier por categoria ≈ spend da categoria */ const suppliers = supNames.map((name, i) => { const catIdx = weightedIndex(rng, catSpend); const lt = randInt(rng, 3, 45); const otif = Math.min(0.99, 0.62 + rng() * 0.37); const risk = +(rng() * 100).toFixed(0); return { id: 'sup' + i, name, cnpj: cnpj(rng), categoryId: 'cat' + catIdx, category: categories[catIdx].name, spend: supSpend[i], share: supSpend[i] / total, leadTime: lt, otif, riskScore: risk, strategic: i < 8, }; }); /* --- Meses (12, com sazonalidade por setor) --- */ const months = buildMonths(rng, profile.sector, total); /* --- Pedidos de Compra (amostra navegável) --- */ const savingRateByClass = { A: 0.045, B: 0.075, C: 0.11 }; let pos = []; let poSeq = 4500000000; suppliers.forEach((sup) => { const nPo = Math.max(1, Math.round((sup.spend / total) * 2400) + randInt(rng, 0, 2)); const w = paretoWeights(rng, nPo); const vals = allocateExact(w, sup.spend); for (let k = 0; k < nPo; k++) { if (pos.length > 2600) break; const cat = categories.find(c => c.id === sup.categoryId); const site = sites[weightedIndex(rng, siteSpend)]; const month = months[weightedIndex(rng, months.map(m => m.spend))]; const lt = Math.max(1, Math.round(sup.leadTime + (rng() - 0.5) * 10)); const maverick = rng() < 0.10; const split = rng() < 0.05; const status = pick(rng, ['Pago', 'Pago', 'Pago', 'Recebido', 'Em aprovação', 'Cotação']); pos.push({ id: String(poSeq++), supplierId: sup.id, supplier: sup.name, categoryId: sup.categoryId, category: sup.category, siteId: site.id, site: site.name, ym: month.ym, value: vals[k], leadTime: lt, status, maverick, split, saving: Math.round(vals[k] * savingRateByClass[cat ? cat.class : 'C']), }); } }); /* --- Saving total + calibração ao anchor roi_valor --- */ let savingTotal = pos.reduce((s, p) => s + p.saving, 0); if (profile.roiValor && savingTotal > 0) { const scale = profile.roiValor / savingTotal; pos.forEach(p => { p.saving = Math.round(p.saving * scale); }); savingTotal = pos.reduce((s, p) => s + p.saving, 0); } /* --- KPIs derivados (consistentes com os registros) --- */ const leadTimes = pos.map(p => p.leadTime).sort((a, b) => a - b); const pctl = (arr, q) => arr.length ? arr[Math.min(arr.length - 1, Math.floor(q * arr.length))] : 0; const otifMean = suppliers.reduce((s, x) => s + x.otif * x.spend, 0) / total; /* ponderado por spend */ const maverickShare = pos.filter(p => p.maverick).reduce((s, p) => s + p.value, 0) / total; /* alavancas de RoAI somando savingTotal (≈ roi_valor quando existe) */ const leverDefs = [ { id: 'cauda', label: 'Consolidação de cauda longa', w: 0.34 }, { id: 'negoc', label: 'Renegociação categorias A', w: 0.28 }, { id: 'maverick', label: 'Redução de maverick buying', w: 0.20 }, { id: 'prazo', label: 'Ganho de prazo de pagamento', w: 0.18 }, ]; const leverVals = allocateExact(leverDefs.map(l => l.w), savingTotal); const roiLevers = leverDefs.map((l, i) => ({ id: l.id, label: l.label, value: leverVals[i] })); return { meta: { company: profile.company, sector: profile.sector, currency: profile.currency, totalSpend: total, supplierCount: suppliers.length, categoryCount: categories.length, siteCount: sites.length, poCount: pos.length, savingTotal, roiAnchor: profile.roiValor, seedKey: profile.seedKey, generatedLabel: 'dados simulados', }, categories, suppliers, sites, months, pos, kpis: { totalSpend: total, savingTotal, leadTimeMean: leadTimes.reduce((s, x) => s + x, 0) / (leadTimes.length || 1), leadTimeP50: pctl(leadTimes, 0.5), leadTimeP90: pctl(leadTimes, 0.9), otif: otifMean, maverickShare, roiLevers, }, }; } /* ---------- helpers de nomes / pesos ---------- */ function uniqueNames(basePool, seedNames, target, rng) { const out = []; const seen = new Set(); const push = (n) => { const k = n.toLowerCase(); if (n && !seen.has(k)) { seen.add(k); out.push(n); } }; (seedNames || []).forEach(push); basePool.forEach(push); let si = 0; while (out.length < target) { const base = basePool[out.length % basePool.length]; const suf = SUFFIXES[si % SUFFIXES.length]; si++; push(`${base} ${suf}`); if (si > target * 4) break; } return out.slice(0, target); } function weightedIndex(rng, weights) { const sum = weights.reduce((s, x) => s + x, 0); let r = rng() * sum; for (let i = 0; i < weights.length; i++) { r -= weights[i]; if (r <= 0) return i; } return weights.length - 1; } function buildMonths(rng, sector, total) { const labels = []; const now = { y: 2026, m: 4 }; /* abr/2026, alinhado ao app */ for (let i = 11; i >= 0; i--) { let m = now.m - i, y = now.y; while (m <= 0) { m += 12; y--; } labels.push(`${y}-${String(m).padStart(2, '0')}`); } /* sazonalidade leve por setor */ const seasonal = labels.map((ym) => { const mm = parseInt(ym.slice(5), 10); let base = 1; if (sector === 'varejo' && (mm === 11 || mm === 12)) base = 1.6; /* black friday/natal */ if (sector === 'alimentos' && (mm >= 3 && mm <= 6)) base = 1.35; /* safra */ if (mm === 12 || mm === 3 || mm === 6 || mm === 9) base *= 1.12; /* fim de trimestre */ return base * (0.85 + rng() * 0.3); }); const wsum = seasonal.reduce((s, x) => s + x, 0); const spend = allocateExact(seasonal.map(x => x / wsum), total); return labels.map((ym, i) => ({ ym, spend: spend[i], poCount: 0 })); } /* ---------- Cache por perfil (em memória) ---------- */ const cache = {}; function getDataset(state) { const p = profileFromState(state); const key = `${p.seedKey}|${Math.round(p.totalSpend)}|${p.roiValor || 0}`; if (cache[key]) return cache[key]; const ds = generateDataset(p); cache[key] = ds; return ds; } window.SyntheticData = { getDataset, generateDataset, profileFromState, fmtBRL, fmtInt, fmtPct, parseBRLNumber, hashString, sectorKey, }; })();