// ===================== planilha do cliente → painel online (Âmbito) ===================== // Para clientes da Âmbito com base de clientes: vendas (do sistema, da ferramenta de CRM ou lançadas à mão), // campanhas de CRM com grupo de controle e, se houver, anúncios da Meta e leads que vieram deles. // Envia ao painel só números: os contatos dos clientes ficam na planilha. Cada cliente vira um número sem // significado, sorteado de novo a cada envio, que só serve para o painel saber que duas compras são da mesma pessoa. // // Como ligar (planilha nova do cliente, Extensões → Apps Script, cole este arquivo): // 1. prepararPlanilhaCliente() cria as abas Config, Vendas, Campanhas, Públicos, Leads, Investimento e Eventos // 2. Na aba Config: PAINEL_EMPRESA (identificador criado na tela Clientes do painel). Com anúncios: // CONTA_ANUNCIOS (act_…), META_TOKEN e RESULTADO_ANUNCIO (mensagens ou formulario). // 3. codigoPareamentoPainel() e cole o código (aba Eventos) na tela Clientes do painel // 4. primeiraCargaAnuncios() só com anúncios: lê os últimos 90 dias da Meta // 5. criarGatilhoPainel() de hora em hora (Meta dos últimos 3 dias + envio) e miniaturas às 6h // 6. enviarPainel() envia agora // Vendas: na aba Vendas (uma linha por venda), em abas "Importar " com a exportação colada do jeito que // sai (Goomer, Poli, PDV: as colunas são achadas pelo cabeçalho) ou pelo formulário de criarFormularioVendas(). // Instagram do mesmo cliente: cole também instagram.gs no mesmo projeto. Os dois usam a mesma aba Config, // a mesma chave de envio e o mesmo token; o envio deste arquivo já leva o bloco do Instagram. const CL_TZ = 'America/Sao_Paulo'; const CL_ABAS = { Config: ['chave', 'valor', 'para que serve'], Vendas: ['Data', 'Cliente (telefone, e-mail, CPF ou código)', 'Valor (R$)', 'Canal', 'Campanha', 'Pedido', 'Observação'], Campanhas: ['Campanha', 'Canal', 'Data do envio', 'Público (pessoas que receberam)', 'Enviados', 'Entregues', 'Lidos ou abertos', 'Cliques ou respostas', 'Conversões (grupo)', 'Receita (grupo)', 'Grupo de controle (pessoas)', 'Conversões (controle)', 'Receita (controle)', 'Custo (R$)', 'Observação'], 'Públicos': ['Campanha', 'Contato (telefone, e-mail, CPF ou código)', 'Grupo (envio ou controle)'], Leads: ['Data', 'Contato (telefone, e-mail, CPF ou código)', 'Origem', 'ID do anúncio', 'Observação'], Investimento: ['Data', 'Campanha', 'Conjunto', 'Anúncio', 'ID do anúncio', 'Investimento', 'Impressões', 'Cliques no link', 'Conversas iniciadas', 'Novos contatos de mensagens', 'Leads (formulário)', 'Visitas à página', 'lido_em'], Eventos: ['quando', 'acao', 'situacao', 'detalhe'], }; const CL_CONFIG_PADRAO = [ ['PAINEL_URL', 'https://painel.ambito.net.br', 'endereço do painel'], ['PAINEL_EMPRESA', '', 'identificador do cliente na tela Clientes do painel'], ['DIAS_INATIVO', '90', 'dias sem comprar para o cliente contar como inativo (em risco a partir de 2/3 disso)'], ['JANELA_CAMPANHA', '7', 'dias depois do envio em que uma compra conta como conversão da campanha (aba Públicos)'], ['DIAS_HISTORICO', '400', 'quantos dias de vendas enviar'], ['CANAIS_FORMULARIO', 'WhatsApp', 'canais do formulário "Lançar venda", separados por vírgula (criarFormularioVendas)'], ['CONTA_ANUNCIOS', '', 'conta de anúncios da Meta (act_…); vazio = sem anúncios'], ['RESULTADO_ANUNCIO', 'mensagens', 'mensagens (anúncio leva ao WhatsApp) ou formulario (anúncio leva a uma página)'], ['META_TOKEN', '', 'cole o token aqui; na primeira leitura ele sai da planilha e fica guardado só no script'], ['IG_USUARIO', '', 'Instagram (instagram.gs): @ do cliente, sem o @'], ['IG_USER_ID', '', 'Instagram: preenchido por descobrirInstagram()'], ['IG_DIAS', '120', 'Instagram: quantos dias de histórico ler e enviar'], ['GRAPH_VERSAO', 'v26.0', 'versão da Graph API da Meta'], ]; const CL_EXPLICA = { Vendas: 'Uma linha por venda. Cole aqui a exportação do sistema (PDV, delivery, e-commerce) ou da ferramenta de CRM, ou lance à mão. Sem o cliente, a venda conta no faturamento mas não na recompra. Com o número do pedido, uma venda colada duas vezes conta uma só.', Campanhas: 'Uma linha por disparo para a base. Preencha os números da ferramenta de CRM; conversões e receita podem ficar vazias se a lista de quem recebeu e do grupo de controle estiver na aba Públicos (o script calcula).', 'Públicos': 'Quem recebeu cada campanha (grupo "envio") e quem ficou de fora de propósito (grupo "controle"). O script cruza com a aba Vendas e calcula conversões e receita de cada grupo.', Leads: 'Opcional: contatos que chegaram pelos anúncios (data, telefone e o ID do anúncio). O script cruza com a aba Vendas para saber quem comprou.', }; // ---------- comandos ---------- /** Cria as abas que faltam (não mexe nas que já existem). */ function prepararPlanilhaCliente() { const ss = SpreadsheetApp.getActive(); Object.keys(CL_ABAS).forEach(nome => { let sh = ss.getSheetByName(nome); if (sh) return; sh = ss.insertSheet(nome); sh.getRange(1, 1, 1, CL_ABAS[nome].length).setValues([CL_ABAS[nome]]).setFontWeight('bold').setWrap(true); sh.setFrozenRows(1); if (CL_EXPLICA[nome]) sh.getRange(1, 1).setNote(CL_EXPLICA[nome]); if (nome === 'Config') sh.getRange(2, 1, CL_CONFIG_PADRAO.length, 3).setValues(CL_CONFIG_PADRAO); if (nome === 'Vendas') { sh.getRange('A:A').setNumberFormat('dd/mm/yyyy'); sh.getRange('C:C').setNumberFormat('"R$" #,##0.00'); sh.getRange('B:B').setNumberFormat('@'); } if (nome === 'Campanhas') { sh.getRange('C:C').setNumberFormat('dd/mm/yyyy'); sh.getRange('J:J').setNumberFormat('"R$" #,##0.00'); sh.getRange('M:N').setNumberFormat('"R$" #,##0.00'); } if (nome === 'Públicos' || nome === 'Leads') sh.getRange('B:B').setNumberFormat('@'); }); ss.setSpreadsheetTimeZone(CL_TZ); clLog_('preparar_planilha', 'ok', 'Abas prontas. Preencha PAINEL_EMPRESA na aba Config.'); return 'ok'; } /** Mostra o código de pareamento (hash da chave de envio; não serve como senha) e registra na aba Eventos. */ function codigoPareamentoPainel() { const h = clHashChave_(); clLog_('painel_pareamento', 'ok', 'Código de pareamento: ' + h); console.log(h); return h; } /** Com anúncios: lê os últimos 90 dias da Meta e envia. */ function primeiraCargaAnuncios() { clLerMeta_(90); clMiniaturas_(); return enviarPainel(); } /** Gatilho de hora em hora: relê os 3 últimos dias da Meta (se houver conta de anúncios) e envia. */ function atualizarPainelHora() { const props = PropertiesService.getScriptProperties(); if (clTemAnuncios_()) { const quando = Utilities.formatDate(new Date(), CL_TZ, 'dd/MM HH:mm'); try { clLerMeta_(3); props.setProperty('CL_META', JSON.stringify({ ok: true, quando: quando })); } catch (e) { props.setProperty('CL_META', JSON.stringify({ ok: false, quando: quando })); clLog_('ler_meta', 'erro', String(e && e.message || e).slice(0, 300)); } } return enviarPainel(); } /** Gatilho diário: miniaturas dos anúncios que gastaram nos últimos 30 dias. */ function atualizarMiniaturasPainel() { try { clMiniaturas_(); } catch (e) { clLog_('miniaturas', 'erro', String(e && e.message || e).slice(0, 300)); } } /** Rode uma vez: cria os gatilhos (sem mexer nos outros gatilhos do projeto). */ function criarGatilhoPainel() { const existentes = ScriptApp.getProjectTriggers().map(t => t.getHandlerFunction()); if (existentes.indexOf('atualizarPainelHora') < 0) ScriptApp.newTrigger('atualizarPainelHora').timeBased().everyHours(1).create(); if (clTemAnuncios_() && existentes.indexOf('atualizarMiniaturasPainel') < 0) ScriptApp.newTrigger('atualizarMiniaturasPainel').timeBased().everyDays(1).atHour(6).create(); clLog_('criar_gatilhos', 'ok', 'Gatilho de hora em hora pronto' + (clTemAnuncios_() ? ', e o das miniaturas às 6h.' : '.')); return 'ok'; } /** Envia agora o resumo (só números) para o painel. */ function enviarPainel() { CL_CFG = null; const cfg = clConfig_(); const url = String(cfg.PAINEL_URL || '').replace(/\/+$/, ''), empresa = String(cfg.PAINEL_EMPRESA || '').trim(); if (!url || !empresa) throw new Error('Preencha PAINEL_URL e PAINEL_EMPRESA na aba Config.'); const corpo = clMontar_(); const texto = JSON.stringify(corpo); const res = UrlFetchApp.fetch(url + '/api/ingest/' + encodeURIComponent(empresa), { method: 'post', contentType: 'application/json', payload: texto, headers: { Authorization: 'Bearer ' + clChave_() }, muteHttpExceptions: true, }); const code = res.getResponseCode(); if (code !== 200) clLog_('enviar_painel', 'erro', code === 401 ? 'Painel ainda não pareado. Código de pareamento: ' + clHashChave_() : 'HTTP ' + code + ' ' + res.getContentText().slice(0, 200)); console.log('painel', code, texto.length + ' bytes'); return { code: code, bytes: texto.length }; } // ---------- montagem do envio ---------- function clMontar_() { const cfg = clConfig_(); const hoje = clDia_(new Date()); const corte = clSomaDias_(hoje, -Math.max(30, Math.min(1500, Number(cfg.DIAS_HISTORICO) || 400))); const corpo = { v: 1, empresa: String(cfg.PAINEL_EMPRESA || '').trim(), gerado_em: new Date().toISOString(), atualizacao: {} }; // vendas: um número por cliente, sorteado a cada envio // vendas da aba Vendas, das abas "Importar …" (exportações do Goomer, da Poli…) e do formulário de lançamento const todas = clTodasVendas_(); const ids = {}, ordem = []; const canais = [], idxCanal = {}, camps = [], idxCamp = {}; const linhas = []; todas.forEach(v => { if (v.dia < corte || v.dia > hoje) return; if (v.id && ids[v.id] == null) { ids[v.id] = ordem.length; ordem.push(v.id); } if (v.canal && idxCanal[v.canal] == null) { idxCanal[v.canal] = canais.length; canais.push(v.canal); } if (v.camp && idxCamp[v.camp] == null) { idxCamp[v.camp] = camps.length; camps.push(v.camp); } linhas.push([v.dia, v.id ? ids[v.id] : -1, Math.round(v.valor * 100) / 100, v.canal ? idxCanal[v.canal] : -1, v.camp ? idxCamp[v.camp] : -1]); }); // sorteio: o número de cada cliente muda a cada envio e não segue a ordem da planilha const sorteio = ordem.map((_, i) => i); for (let i = sorteio.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = sorteio[i]; sorteio[i] = sorteio[j]; sorteio[j] = t; } linhas.forEach(l => { if (l[1] >= 0) l[1] = sorteio[l[1]]; }); linhas.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] - b[1])); if (todas.length || clExiste_('Vendas')) corpo.base = { dias_inativo: Math.max(15, Math.min(730, Number(cfg.DIAS_INATIVO) || 90)), canais: canais, campanhas: camps, vendas: linhas }; // compras de cada contato (para campanhas e leads), pelo identificador normalizado const compras = {}; todas.forEach(v => { if (v.id) (compras[v.id] = compras[v.id] || []).push({ dia: v.dia, valor: v.valor }); }); Object.keys(compras).forEach(k => compras[k].sort((a, b) => (a.dia < b.dia ? -1 : 1))); // campanhas de CRM: números lançados na aba; o que estiver vazio é calculado pela aba Públicos const C = clLer_('Campanhas'); if (C.rows.length) { const janela = Math.max(1, Math.min(60, Number(cfg.JANELA_CAMPANHA) || 7)); const P = clLer_('Públicos'), pub = {}; P.rows.forEach(r => { const nome = String(r[0] || '').trim(), id = clId_(r[1]); if (!nome || !id) return; const g = /contr/i.test(String(r[2] || '')) ? 'controle' : 'envio'; const o = pub[nome] = pub[nome] || { envio: {}, controle: {} }; o[g][id] = true; }); const resultado = (contatos, ini) => { const fim = clSomaDias_(ini, janela - 1); let conv = 0, rec = 0; Object.keys(contatos).forEach(id => { const c = (compras[id] || []).filter(x => x.dia >= ini && x.dia <= fim); if (c.length) { conv++; rec += c.reduce((s, x) => s + x.valor, 0); } }); return { n: Object.keys(contatos).length, conv: conv, rec: Math.round(rec * 100) / 100 }; }; const n = v => (v === '' || v == null || isNaN(clNum_(v)) ? '' : clNum_(v)); const vazio = v => v === '' || v == null; corpo.crm_campanhas = C.rows.map(r => { const nome = String(r[0] || '').trim(), dia = clTexto_(r[2]); if (!nome || !dia || dia < corte) return null; const lin = [nome.slice(0, 80), String(r[1] || '').trim().slice(0, 30), dia, n(r[3]), n(r[4]), n(r[5]), n(r[6]), n(r[7]), n(r[8]), n(r[9]), n(r[10]), n(r[11]), n(r[12]), n(r[13])]; const p = pub[nome]; // a lista da aba Públicos só conta depois que a janela da campanha fechou ou a partir de hoje if (p && dia <= hoje) { const e = resultado(p.envio, dia), c = resultado(p.controle, dia); if (vazio(lin[3]) && e.n) lin[3] = e.n; if (vazio(lin[8]) && e.n) lin[8] = e.conv; if (vazio(lin[9]) && e.n) lin[9] = e.rec; if (vazio(lin[10]) && c.n) lin[10] = c.n; if (vazio(lin[11]) && c.n) lin[11] = c.conv; if (vazio(lin[12]) && c.n) lin[12] = c.rec; } return lin; }).filter(Boolean); } // anúncios da Meta (aba Investimento) e leads que vieram deles const I = clLer_('Investimento'); if (I.rows.length) { const formulario = /form/i.test(String(cfg.RESULTADO_ANUNCIO || '')); const ads = [], idxAd = {}; corpo.meta = I.rows.map(r => { const dia = clTexto_(r[0]), id = String(r[4] || '').trim(); if (!dia || dia < corte || !id) return null; if (idxAd[id] == null) { idxAd[id] = ads.length; ads.push([String(r[1] || ''), String(r[2] || ''), String(r[3] || ''), 'ANÚNCIOS', '', id]); } const conversas = formulario ? clNum_(r[11]) : clNum_(r[8]), novos = formulario ? clNum_(r[10]) : clNum_(r[9]); return [dia, idxAd[id], Math.round(clNum_(r[5]) * 100) / 100, clNum_(r[6]), clNum_(r[7]), conversas, novos]; }).filter(Boolean); corpo.ads = ads; corpo.linhas = ['ANÚNCIOS']; corpo.vendedores = []; let st = {}; try { st = JSON.parse(PropertiesService.getScriptProperties().getProperty('CL_META') || '{}'); } catch (e) {} corpo.atualizacao.meta_ok = st.ok !== false; corpo.atualizacao.quando = st.quando || ''; const L = clLer_('Leads'); if (L.rows.length) { const leads = [], negocios = [], criados = {}; let inicio = ''; L.rows.forEach(r => { const dia = clTexto_(r[0]); if (!dia || dia < corte || dia > hoje) return; const id = clId_(r[1]), ad = String(r[3] || '').trim(); const ai = ad && idxAd[ad] != null ? idxAd[ad] : -1, linha = ai >= 0 || /an[uú]ncio|meta|ads/i.test(String(r[2] || '')) ? 'ANÚNCIOS' : ''; const origem = String(r[2] || '').trim().slice(0, 40) || (ai >= 0 ? 'Anúncio' : 'Sem origem'); leads.push([dia, '', linha, ai, origem, '']); criados[dia] = (criados[dia] || 0) + 1; if (!inicio || dia < inicio) inicio = dia; // comprou depois de chegar? a primeira compra a partir do dia do lead (até 90 dias) é a venda do anúncio const c = id ? (compras[id] || []).find(x => x.dia >= dia && x.dia <= clSomaDias_(dia, 89)) : null; const dias = c ? Math.round((new Date(c.dia + 'T12:00:00Z') - new Date(dia + 'T12:00:00Z')) / 864e5) : ''; // sem compra em 90 dias, o lead sai de "aberto" (conta como perdido nesse dia) const venceu = !c && clSomaDias_(dia, 90) <= hoje; negocios.push(['Leads de anúncio', c ? 'Comprou' : venceu ? 'Não comprou' : 'Ainda não comprou', c ? 'ganho' : venceu ? 'perdido' : 'aberto', '', c ? c.dia : '', venceu ? clSomaDias_(dia, 90) : '', venceu ? 'Não comprou em 90 dias' : '', c ? Math.round(c.valor * 100) / 100 : '', '', linha, ai, dias, c ? c.dia : venceu ? clSomaDias_(dia, 90) : dia, origem, '']); }); corpo.leads = leads; corpo.negocios = negocios; corpo.inicio_crm = inicio || hoje; corpo.criados = Object.keys(criados).sort().map(d => [d, 'leads', criados[d]]); corpo.etapas = { 'Leads de anúncio': ['Ainda não comprou'] }; corpo.sem_qualificacao = true; } if (formulario) corpo.vocabulario = [ ['Conversas iniciadas (Meta)', 'Visitas à página (Meta)'], ['Conversas iniciadas', 'Visitas à página'], ['conversas iniciadas', 'visitas à página'], ['Custo por conversa', 'Custo por visita'], ['custo por conversa', 'custo por visita'], ['por conversa', 'por visita'], ['Conversas', 'Visitas à página'], ['conversas', 'visitas'], ['Novos contatos (Meta)', 'Formulários (Meta)'], ['Novos contatos', 'Formulários'], ['novos contatos', 'formulários'], ]; } // Instagram e cliques do link da bio, quando instagram.gs está no mesmo projeto if (typeof montarInstagram_ === 'function' && clExiste_('Perfil')) { try { corpo.instagram = montarInstagram_(); let st = {}; try { st = JSON.parse(PropertiesService.getScriptProperties().getProperty('IG_STATUS') || '{}'); } catch (e) {} corpo.atualizacao.ig_ok = st.ok !== false; if (!corpo.atualizacao.quando) corpo.atualizacao.quando = st.quando || ''; const site = typeof igSite_ === 'function' ? igSite_() : null; if (site) corpo.site = site; } catch (e) { clLog_('enviar_painel', 'atencao', 'Instagram ficou de fora deste envio: ' + String(e && e.message || e).slice(0, 200)); } } return corpo; } // ---------- vendas: aba Vendas, abas de importação e formulário ---------- /** * Todas as vendas, já limpas: data, cliente (identificador normalizado), valor, canal, campanha. * Abas cujo nome começa com "Importar" recebem a exportação colada de qualquer sistema (Goomer, Poli, PDV…): * as colunas são achadas pelo cabeçalho e o canal, quando a exportação não traz, é o resto do nome da aba * ("Importar Goomer" → Goomer). Pedido cancelado fica de fora. O mesmo pedido colado duas vezes conta uma. */ function clTodasVendas_() { const out = [], vistos = {}; const add = (dia, cliente, valor, canal, camp, pedido) => { dia = clTexto_(dia); valor = clNum_(valor); if (!dia || !(valor > 0)) return; canal = String(canal || '').trim().slice(0, 60); pedido = String(pedido || '').trim(); if (pedido) { const k = canal.toLowerCase() + '|' + pedido; if (vistos[k]) return; vistos[k] = true; } out.push({ dia: dia, id: clId_(cliente), valor: valor, canal: canal, camp: String(camp || '').trim().slice(0, 80) }); }; clLer_('Vendas').rows.forEach(r => add(r[0], r[1], r[2], r[3], r[4], r[5])); SpreadsheetApp.getActive().getSheets().forEach(sh => { const nome = sh.getName(); if (!/^importar/i.test(nome) || sh.getLastRow() < 2) return; const dados = sh.getRange(1, 1, sh.getLastRow(), sh.getLastColumn()).getValues(); const c = clColunasImportacao_(dados[0]); if (c.data < 0 || c.valor < 0) { clLog_('importar', 'atencao', 'Aba "' + nome + '": não achei as colunas de data e de valor no cabeçalho.'); return; } const canalAba = (/formul/i.test(nome) && String(clConfig_().CANAL_FORMULARIO || '').trim()) || nome.replace(/^importar\s*/i, '').trim() || 'Importação'; dados.slice(1).forEach(r => { if (c.status >= 0 && /cancel|recus|estorn|devolv|rejeit/i.test(String(r[c.status] || ''))) return; const canal = c.canal >= 0 && String(r[c.canal] || '').trim() ? String(r[c.canal]).trim() : canalAba; add(r[c.data], c.cliente >= 0 ? r[c.cliente] : '', r[c.valor], canal, c.camp >= 0 ? r[c.camp] : '', c.pedido >= 0 ? r[c.pedido] : ''); }); }); return out.sort((a, b) => (a.dia < b.dia ? -1 : a.dia > b.dia ? 1 : 0)); } // acha as colunas pelo cabeçalho (sem acento, em minúsculas) function clColunasImportacao_(cab) { const h = cab.map(x => String(x || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()); const acha = (bons, ruins) => { for (let t = 0; t < bons.length; t++) { const i = h.findIndex(x => x && bons[t].test(x) && !(ruins && ruins.test(x))); if (i >= 0) return i; } return -1; }; return { data: acha([/data da venda|data do pedido|data pedido|data_pedido|data da compra/, /^data|data$|^date|criado em|realizado em|feito em/], /carimbo|nascimento|atualiz|entrega prevista|cadastro/), cliente: acha([/whats|celular|telefone|fone|phone/, /e-?mail/, /cpf/, /(id|codigo) (do )?cliente|cliente (id|codigo)/, /^cliente$/]), valor: acha([/valor total|total do pedido|total pago|valor pago|^total$|valor da venda|valor do pedido/, /total|valor|preco|amount/], /frete|taxa|desconto|subtotal|entrega|troco|gorjeta/), pedido: acha([/(numero|n[ºo°]|id|codigo) do pedido|pedido (numero|n[ºo°]|id)|^pedido$|numero do pedido/, /^id$|^numero$|^codigo$/]), canal: acha([/^canal|origem do pedido|plataforma|tipo de pedido|modalidade|^origem$/]), camp: acha([/campanha|cupom|utm_campaign/]), status: acha([/status|situacao/]), }; } /** * Rode uma vez: cria o formulário "Lançar venda" ligado a esta planilha (aba "Importar formulário"). * Serve para quem vende pelo WhatsApp: a equipe lança cada venda pelo celular em poucos segundos. * Os canais oferecidos vêm de CANAIS_FORMULARIO na aba Config (separados por vírgula). */ function criarFormularioVendas() { const ss = SpreadsheetApp.getActive(); const cfg = clConfig_(); if (clExiste_('Importar formulário')) { clLog_('formulario_vendas', 'atencao', 'O formulário já existe (aba Importar formulário).'); return 'já existe'; } const canais = String(cfg.CANAIS_FORMULARIO || 'WhatsApp').split(',').map(x => x.trim()).filter(Boolean); const nome = String(cfg.NOME_CLIENTE || ss.getName().replace(/\s*·\s*Dados\s*$/, '')).trim(); const f = FormApp.create('Lançar venda · ' + nome); f.setDescription('Uma resposta por venda. O telefone do cliente fica só na planilha da empresa: é ele que mostra quem voltou a comprar.'); f.setCollectEmail(false).setShowLinkToRespondAgain(true).setConfirmationMessage('Venda lançada. Pode lançar a próxima.'); f.addDateItem().setTitle('Data da venda').setRequired(true); f.addTextItem().setTitle('WhatsApp do cliente').setHelpText('Com DDD. Ex.: 15 99999-0000').setRequired(true); f.addTextItem().setTitle('Valor da venda').setHelpText('Só o número. Ex.: 89,90').setRequired(true); if (canais.length > 1) f.addMultipleChoiceItem().setTitle('Canal').setChoiceValues(canais).setRequired(true); f.addTextItem().setTitle('Campanha ou cupom').setHelpText('Se a venda veio de uma campanha, o nome dela ou o cupom usado.'); f.addTextItem().setTitle('Número do pedido').setHelpText('Opcional. Evita contar a mesma venda duas vezes.'); f.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); SpreadsheetApp.flush(); Utilities.sleep(2000); const aba = ss.getSheets().find(sh => { try { return sh.getFormUrl() && sh.getFormUrl().indexOf(f.getId()) >= 0; } catch (e) { return false; } }); if (aba) { aba.setName('Importar formulário'); // com um canal só, o formulário não pergunta: a venda entra com esse canal if (canais.length === 1) clSetConfig_('CANAL_FORMULARIO', canais[0]); } clSetConfig_('FORMULARIO_VENDAS', f.getPublishedUrl()); clLog_('formulario_vendas', 'ok', 'Formulário criado: ' + f.getPublishedUrl() + ' (para editar: ' + f.getEditUrl() + ')'); return f.getPublishedUrl(); } // ---------- Meta Ads ---------- function clTemAnuncios_() { return /^act_\d{5,25}$/.test(String(clConfig_().CONTA_ANUNCIOS || '').trim()); } /** Lê na Meta os últimos "dias" dias, por anúncio e dia, e atualiza a aba Investimento. */ function clLerMeta_(dias) { const conta = String(clConfig_().CONTA_ANUNCIOS || '').trim(); if (!/^act_\d{5,25}$/.test(conta)) throw new Error('Preencha CONTA_ANUNCIOS (act_…) na aba Config.'); const hoje = clDia_(new Date()), desde = clSomaDias_(hoje, -(dias - 1)); let url = clReq_(conta + '/insights', { level: 'ad', time_increment: 1, limit: 500, time_range: JSON.stringify({ since: desde, until: hoje }), fields: 'ad_id,ad_name,adset_name,campaign_name,spend,impressions,inline_link_clicks,actions', }); const novas = {}; const acao = (lista, tipos) => { let n = 0; (lista || []).forEach(a => { if (tipos.indexOf(a.action_type) >= 0) n = Math.max(n, Number(a.value) || 0); }); return n; }; for (let p = 0; url && p < 40; p++) { const j = clGet_(url); (j.data || []).forEach(x => { const k = x.date_start + '|' + x.ad_id; novas[k] = [x.date_start, x.campaign_name || '', x.adset_name || '', x.ad_name || '', String(x.ad_id), Number(x.spend) || 0, Number(x.impressions) || 0, Number(x.inline_link_clicks) || 0, acao(x.actions, ['onsite_conversion.messaging_conversation_started_7d']), acao(x.actions, ['onsite_conversion.messaging_first_reply']), acao(x.actions, ['lead', 'onsite_web_lead', 'offsite_conversion.fb_pixel_lead']), acao(x.actions, ['landing_page_view'])]; }); url = j.paging && j.paging.next ? j.paging.next : null; } // regrava a aba: mantém os dias fora da janela lida e troca os de dentro const ss = SpreadsheetApp.getActive(); let sh = ss.getSheetByName('Investimento'); if (!sh) { prepararPlanilhaCliente(); sh = ss.getSheetByName('Investimento'); } const h = CL_ABAS.Investimento; const atuais = sh.getLastRow() > 1 ? sh.getRange(2, 1, sh.getLastRow() - 1, h.length).getValues() : []; const fica = atuais.filter(r => { const d = clTexto_(r[0]); return d && (d < desde || d > hoje); }).map(r => r.map((v, i) => (i === 0 ? clTexto_(v) : v))); const agora = Utilities.formatDate(new Date(), CL_TZ, 'yyyy-MM-dd HH:mm'); const todas = fica.concat(Object.keys(novas).map(k => novas[k].concat([agora]))).sort((a, b) => (a[0] < b[0] ? 1 : a[0] > b[0] ? -1 : 0)); if (sh.getLastRow() > 1) sh.getRange(2, 1, sh.getLastRow() - 1, h.length).clearContent(); if (todas.length) { sh.getRange(2, 1, todas.length, 5).setNumberFormat('@'); sh.getRange(2, 1, todas.length, h.length).setValues(todas.map(r => r.map((v, i) => (i <= 4 ? clSeguro_(String(v)) : v)))); } clLog_('ler_meta', 'ok', Object.keys(novas).length + ' linhas de ' + desde + ' a ' + hoje); return Object.keys(novas).length; } /** Miniaturas (120 px) dos anúncios que gastaram nos últimos 30 dias, enviadas ao painel. */ function clMiniaturas_() { if (!clTemAnuncios_()) return 0; const cfg = clConfig_(); const corte = clSomaDias_(clDia_(new Date()), -30), I = clLer_('Investimento'), ids = {}; I.rows.forEach(r => { if (clTexto_(r[0]) >= corte && clNum_(r[5]) > 0) ids[String(r[4]).trim()] = true; }); const lista = Object.keys(ids).filter(id => /^\d{5,25}$/.test(id)); if (!lista.length) return 0; const reqs = lista.map(id => ({ url: clReq_(id, { fields: 'creative{thumbnail_url}', thumbnail_width: 120, thumbnail_height: 120 }), muteHttpExceptions: true })); const res = clFetchAll_(reqs); const imgs = []; res.forEach((j, i) => { const u = j && j.creative && j.creative.thumbnail_url; if (u) imgs.push({ id: lista[i], url: u }); }); const fotos = clFetchAll_(imgs.map(x => ({ url: x.url, muteHttpExceptions: true })), true); const mini = {}; fotos.forEach((b, i) => { if (b && b.tipo && b.base64.length < 90000) mini[imgs[i].id] = 'data:' + b.tipo + ';base64,' + b.base64; }); if (!Object.keys(mini).length) return 0; const url = String(cfg.PAINEL_URL || '').replace(/\/+$/, '') + '/api/ingest/' + encodeURIComponent(String(cfg.PAINEL_EMPRESA || '').trim()) + '/miniaturas'; UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ miniaturas: mini }), headers: { Authorization: 'Bearer ' + clChave_() }, muteHttpExceptions: true }); clLog_('miniaturas', 'ok', Object.keys(mini).length + ' miniaturas enviadas'); return Object.keys(mini).length; } // ---------- utilidades ---------- let CL_CFG = null; function clConfig_() { if (CL_CFG) return CL_CFG; const sh = SpreadsheetApp.getActive().getSheetByName('Config'); const out = {}; if (sh && sh.getLastRow() >= 2) sh.getRange(2, 1, sh.getLastRow() - 1, 2).getValues().forEach(r => { const k = String(r[0] || '').trim(); if (k) out[k] = r[1]; }); CL_CFG = out; return out; } function clSetConfig_(chave, valor) { CL_CFG = null; const sh = SpreadsheetApp.getActive().getSheetByName('Config'); const vals = sh.getRange(2, 1, Math.max(1, sh.getLastRow() - 1), 1).getValues(); for (let i = 0; i < vals.length; i++) if (String(vals[i][0]).trim() === chave) { sh.getRange(i + 2, 2).setValue(valor); return; } sh.appendRow([chave, valor, '']); } // o token sai da planilha na primeira leitura e fica só nas Propriedades do script (o mesmo do instagram.gs) function clToken_() { const props = PropertiesService.getScriptProperties(); const naPlanilha = String(clConfig_().META_TOKEN || '').trim(); if (naPlanilha && naPlanilha.indexOf('guardado') < 0) { props.setProperty('META_TOKEN', naPlanilha); clSetConfig_('META_TOKEN', '(guardado no script em ' + Utilities.formatDate(new Date(), CL_TZ, 'dd/MM/yyyy') + ')'); } const t = props.getProperty('META_TOKEN'); if (!t) throw new Error('Cole o token da Meta em META_TOKEN, na aba Config.'); return t; } function clReq_(caminho, params) { const q = Object.keys(params || {}).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])).join('&'); return 'https://graph.facebook.com/' + String(clConfig_().GRAPH_VERSAO || 'v26.0').trim() + '/' + caminho + (q ? '?' + q : ''); } function clGet_(url) { const r = UrlFetchApp.fetch(url, { headers: { Authorization: 'Bearer ' + clToken_() }, muteHttpExceptions: true }); const j = JSON.parse(r.getContentText() || '{}'); if (j.error) throw new Error('Meta: ' + (j.error.message || 'erro') + ' (código ' + j.error.code + ')'); return j; } // em lotes de 5, com pausa (o Apps Script limita chamadas seguidas ao mesmo endereço) function clFetchAll_(reqs, binario) { const out = []; for (let i = 0; i < reqs.length; i += 5) { const lote = reqs.slice(i, i + 5).map(q => (binario ? q : Object.assign({ headers: { Authorization: 'Bearer ' + clToken_() } }, q))); let res = null; for (let t = 0; t < 4 && !res; t++) { try { res = UrlFetchApp.fetchAll(lote); } catch (e) { if (!/rateMax|muitas vezes|too many times/i.test(String(e && e.message || e)) || t === 3) throw e; Utilities.sleep(2000 * (t + 1)); } } res.forEach(r => { if (binario) { const tipo = String(r.getHeaders()['Content-Type'] || r.getHeaders()['content-type'] || '').split(';')[0]; out.push(r.getResponseCode() === 200 && /^image\//.test(tipo) ? { tipo: tipo, base64: Utilities.base64Encode(r.getContent()) } : null); } else { try { out.push(JSON.parse(r.getContentText() || '{}')); } catch (e) { out.push({}); } } }); if (i + 5 < reqs.length) Utilities.sleep(350); } return out; } function clExiste_(nome) { return !!SpreadsheetApp.getActive().getSheetByName(nome); } function clLer_(nome) { const sh = SpreadsheetApp.getActive().getSheetByName(nome); if (!sh || sh.getLastRow() < 2) return { rows: [] }; const n = Math.max(CL_ABAS[nome] ? CL_ABAS[nome].length : 1, 1); return { rows: sh.getRange(2, 1, sh.getLastRow() - 1, Math.min(n, sh.getLastColumn())).getValues().filter(r => r.some(v => v !== '' && v != null)) }; } function clDia_(d) { return Utilities.formatDate(d, CL_TZ, 'yyyy-MM-dd'); } // datas como Data da planilha, dd/mm/aaaa (com ou sem hora) ou aaaa-mm-dd function clTexto_(v) { if (v instanceof Date) return isNaN(v.getTime()) ? '' : clDia_(v); const s = String(v == null ? '' : v).trim(); let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); if (m) return m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2); m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? m[0] : ''; } function clSomaDias_(dia, n) { const d = new Date(dia + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); } // número da planilha ou texto em pt-BR ("R$ 1.234,56") function clNum_(v) { if (typeof v === 'number') return v; let t = String(v == null ? '' : v).replace(/[^\d,.\-]/g, ''); if (!t) return NaN; if (t.indexOf(',') >= 0) t = t.replace(/\./g, '').replace(',', '.'); else if (/^-?\d{1,3}(\.\d{3})+$/.test(t)) t = t.replace(/\./g, ''); const n = Number(t); return isFinite(n) ? n : NaN; } // identificador do cliente: telefone/CPF pelos últimos 11 dígitos (com ou sem 55), e-mail ou código em minúsculas function clId_(v) { const s = String(v == null ? '' : v).trim(); if (!s) return ''; if (s.indexOf('@') > 0) return s.toLowerCase(); let d = s.replace(/\D/g, ''); // telefone ou CPF: quase só dígitos (parênteses, traço, ponto e espaço não contam) if (d.length >= 10 && d.length <= 13 && /^[\d\s()+.\-]+$/.test(s)) { if (d.length >= 12 && d.indexOf('55') === 0) d = d.slice(2); if (d.length === 10 && /[6-9]/.test(d.charAt(2))) d = d.slice(0, 2) + '9' + d.slice(2); // celular sem o 9 da frente return d; } return s.toLowerCase().replace(/\s+/g, ' '); } function clSeguro_(t) { return /^[=+\-@]/.test(t) ? "'" + t : t; } function clChave_() { const props = PropertiesService.getScriptProperties(); let chave = props.getProperty('PAINEL_CHAVE'); if (!chave) { chave = (Utilities.getUuid() + Utilities.getUuid()).replace(/-/g, ''); props.setProperty('PAINEL_CHAVE', chave); } return chave; } function clHashChave_() { const b = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, clChave_(), Utilities.Charset.UTF_8); return b.map(x => ('0' + ((x + 256) % 256).toString(16)).slice(-2)).join(''); } function clLog_(acao, situacao, detalhe) { const ss = SpreadsheetApp.getActive(); let sh = ss.getSheetByName('Eventos'); if (!sh) { sh = ss.insertSheet('Eventos'); sh.getRange(1, 1, 1, 4).setValues([CL_ABAS.Eventos]).setFontWeight('bold'); sh.setFrozenRows(1); } sh.insertRowAfter(1); sh.getRange(2, 1, 1, 4).setValues([[Utilities.formatDate(new Date(), CL_TZ, 'dd/MM/yyyy HH:mm'), acao, situacao, String(detalhe || '').slice(0, 500)]]); if (sh.getLastRow() > 600) sh.deleteRows(601, sh.getLastRow() - 600); }