Scripts Úteis

Vou abrir esse tópico, já que com a IA, muita coisa que era extremamente técnica hoje encontra soluções simples. Fica o espaço disponível para compartilhar códigos e scripts úteis, de preferência em ambiente amigável para nível básico e intermediário.

Deixo a primeiro página aqui dedicada para linkar diretamente os mais votados:

★

10 curtidas

PTAX Histórico

Deixo em primeiro um script simples para se obter a PTAX atualizada diretamente pelo Google Sheets utilizando a extensão (nativa) Apps Script:

Não é 100% automatizado, então você precisa:

  1. Criar uma aba chamada exatamente “PTAX Histórico”
  2. Ela deve conter 3 colunas pelo menos
  3. A primeira coluna deve ser da data
  4. A segunda deve ser do valor de compra
  5. A terceira do valor de venda
  6. O nome realmente não importa, mas criam-se os intervalos nomeados “Data”, “Compra”, “Venda”
  7. Vá no Apps Script e substitua o Code.gs pelo seguinte código e execute.
/**
 * Atualiza automaticamente as colunas de PTAX Compra e Venda
 * na aba "PTAX Histórico", com base em datas úteis listadas na coluna "Data".
 * Usa a API do Banco Central (SGS), com chamadas seguras e tolerância a falhas.
 */

function atualizarHistoricoPTAX() {
  const planilha = SpreadsheetApp.getActiveSpreadsheet();
  const aba = planilha.getSheetByName("PTAX Histórico");

  const intervaloDatas = planilha.getRangeByName("Data").getValues();
  const intervaloCompra = planilha.getRangeByName("Compra");
  const intervaloVenda = planilha.getRangeByName("Venda");

  const valoresCompra = intervaloCompra.getValues();
  const valoresVenda = intervaloVenda.getValues();

  for (let i = 0; i < intervaloDatas.length; i++) {
    const data = intervaloDatas[i][0];
    if (!data || Object.prototype.toString.call(data) !== "[object Date]") continue;

    const jaTemCompra = valoresCompra[i][0];
    const jaTemVenda = valoresVenda[i][0];

    // Pula se já houver valores válidos (não vazio, não N/D, não Erro)
    if (ehValorValido(jaTemCompra) && ehValorValido(jaTemVenda)) continue;

    const dataFormatada = Utilities.formatDate(new Date(data), "GMT-3", "dd/MM/yyyy");

    try {
      const urlCompra = `https://api.bcb.gov.br/dados/serie/bcdata.sgs.1/dados?formato=json&dataInicial=${dataFormatada}&dataFinal=${dataFormatada}`;
      const urlVenda  = `https://api.bcb.gov.br/dados/serie/bcdata.sgs.10813/dados?formato=json&dataInicial=${dataFormatada}&dataFinal=${dataFormatada}`;

      const respostaCompra = UrlFetchApp.fetch(urlCompra, {muteHttpExceptions: true});
      const respostaVenda  = UrlFetchApp.fetch(urlVenda, {muteHttpExceptions: true});

      const conteudoCompra = respostaCompra.getContentText().trim();
      const conteudoVenda  = respostaVenda.getContentText().trim();

      // Verifica se a resposta da API é JSON esperado (começa com [)
      if (!conteudoCompra.startsWith("[") || !conteudoVenda.startsWith("[")) {
        Logger.log(`⚠️ Conteúdo inesperado para ${dataFormatada}. Pulando linha ${i + 2}.`);
        intervaloCompra.getCell(i + 1, 1).setValue("N/D");
        intervaloVenda.getCell(i + 1, 1).setValue("N/D");
        continue;
      }

      const dadosCompra = JSON.parse(conteudoCompra);
      const dadosVenda  = JSON.parse(conteudoVenda);

      if (dadosCompra.length > 0 && dadosVenda.length > 0) {
        const valorCompra = parseFloat(dadosCompra[0].valor.replace(",", "."));
        const valorVenda  = parseFloat(dadosVenda[0].valor.replace(",", "."));

        intervaloCompra.getCell(i + 1, 1).setValue(valorCompra);
        intervaloVenda.getCell(i + 1, 1).setValue(valorVenda);
        Logger.log(`✅ ${dataFormatada} atualizada: Compra ${valorCompra} / Venda ${valorVenda}`);
      } else {
        intervaloCompra.getCell(i + 1, 1).setValue("N/D");
        intervaloVenda.getCell(i + 1, 1).setValue("N/D");
        Logger.log(`⚠️ Sem dados retornados para ${dataFormatada}. Marcado como N/D.`);
      }

      Utilities.sleep(300); // Respeita limite da API
    } catch (e) {
      intervaloCompra.getCell(i + 1, 1).setValue("Erro");
      intervaloVenda.getCell(i + 1, 1).setValue("Erro");
      Logger.log(`❌ Erro ao buscar PTAX para ${dataFormatada} (linha ${i + 2}): ${e}`);
    }
  }

  Logger.log("✅ PTAX atualizada até " + new Date().toLocaleString("pt-BR"));
}

/**
 * Verifica se o valor é preenchido e válido.
 */
function ehValorValido(valor) {
  return valor !== "" && valor !== "N/D" && valor !== "Erro";
}

Uma dica, você pode criar uma outra aba com os feriados do ano para filtrar os dias, então, na célula A2, da data, você pode colocar 02/01/2024, para marcar o início, e depois criar uma fórmula no seguinte estilo:

=SE(A2<>“”; DIATRABALHO.INTL(A2; 1; 1; Feriados!A$2:A); “”)

Eu não sugiro automatizar totalmente a obtenção da PTAX pois o BC tem feriados técnicos que podem surgir como dias úteis ou serem pontos facultativos. De toda forma, é uma forma de evitar uma poluição de dados muito grande e você obtém os dados direto do Banco Central. Eu fortemente sugiro checagem manual se houve cotação no dia de interesse.

6 curtidas

PTAX Histórico Janela 7 dias

Aqui apenas uma continuação (ou substituição, cumprida a etapa acima PTAX Histórico), pois você não quer um script correndo 5000 dias todas as vezes que é executado (não esqueça de criar um gatilho diário):. Então, depois de executar o acima, siga com esse aqui:

function atualizarPTAXJanela7dias() {
  const planilha = SpreadsheetApp.getActiveSpreadsheet();
  const aba = planilha.getSheetByName("PTAX Histórico");

  const intervaloDatas = planilha.getRangeByName("Data").getValues();
  const intervaloCompra = planilha.getRangeByName("Compra").getValues();
  const intervaloVenda = planilha.getRangeByName("Venda").getValues();

  const hoje = new Date();
  const hojeLimpo = new Date(hoje.getFullYear(), hoje.getMonth(), hoje.getDate());

  for (let i = 0; i < intervaloDatas.length; i++) {
    const data = intervaloDatas[i][0];
    if (!data || Object.prototype.toString.call(data) !== "[object Date]") continue;

    const compra = intervaloCompra[i][0];
    const venda = intervaloVenda[i][0];

    const dataLimpa = new Date(data.getFullYear(), data.getMonth(), data.getDate());
    const diasDiferenca = (dataLimpa - hojeLimpo) / (1000 * 60 * 60 * 24);

    if (diasDiferenca < -7 || diasDiferenca > 7) continue; // fora da janela de 7 dias
    if (ehValorValido(compra) && ehValorValido(venda)) continue;

    const dataFormatada = Utilities.formatDate(dataLimpa, "GMT-3", "dd/MM/yyyy");

    try {
      const urlCompra = `https://api.bcb.gov.br/dados/serie/bcdata.sgs.1/dados?formato=json&dataInicial=${dataFormatada}&dataFinal=${dataFormatada}`;
      const urlVenda  = `https://api.bcb.gov.br/dados/serie/bcdata.sgs.10813/dados?formato=json&dataInicial=${dataFormatada}&dataFinal=${dataFormatada}`;

      const respostaCompra = UrlFetchApp.fetch(urlCompra, {muteHttpExceptions: true});
      const respostaVenda  = UrlFetchApp.fetch(urlVenda, {muteHttpExceptions: true});

      const conteudoCompra = respostaCompra.getContentText().trim();
      const conteudoVenda  = respostaVenda.getContentText().trim();

      if (!conteudoCompra.startsWith("[") || !conteudoVenda.startsWith("[")) {
        Logger.log(`⚠️ Sem retorno para ${dataFormatada}. Pulando linha ${i + 2}.`);
        continue;
      }

      const dadosCompra = JSON.parse(conteudoCompra);
      const dadosVenda  = JSON.parse(conteudoVenda);

      if (dadosCompra.length > 0 && dadosVenda.length > 0) {
        const valorCompra = parseFloat(dadosCompra[0].valor.replace(",", "."));
        const valorVenda  = parseFloat(dadosVenda[0].valor.replace(",", "."));

        aba.getRange(i + 2, 2).setValue(valorCompra);
        aba.getRange(i + 2, 3).setValue(valorVenda);
        Logger.log(`✅ ${dataFormatada} atualizada: Compra ${valorCompra} / Venda ${valorVenda}`);
      } else {
        Logger.log(`⚠️ Nenhum dado para ${dataFormatada}.`);
      }

      Utilities.sleep(300);
    } catch (e) {
      aba.getRange(i + 2, 2).setValue("Erro");
      aba.getRange(i + 2, 3).setValue("Erro");
      Logger.log(`❌ Erro ao buscar PTAX para ${dataFormatada} (linha ${i + 2}): ${e}`);
    }
  }

  Logger.log("✅ Janela dinâmica de 7 dias concluída às " + new Date().toLocaleString("pt-BR"));
}

function ehValorValido(valor) {
  return valor !== "" && valor !== "N/D" && valor !== "Erro";
}

Resultado:
Captura de Tela 2025-06-20 às 18.24.37

9 curtidas

Só para variar o Tesouro Direto mudou o layout do site. Quando isso acontece o site que eu uso para buscar os valores atualizados dos títulos não se atualiza por alguns dias, como eu utilizo esse site para alimentar a funçao importxml na minha planilha, com isso ela também não atualiza os valores dos títulos do Tesouro Direto.

O site que eu uso é esse:

Alguem tem alguma idéia de como ter os dados do Tesouro Direto atualizados de forma automatizada na planilha?

4 curtidas

Consegui!.. Na verdade eu achei na internet.

É só usar o código:

IMPORTDATA(“https://www.tesourodireto.com.br/documents/d/guest/rendimento-resgatar-csv?download=true”; “;”)

Esse código é para os resgates, se quiser para investir é só trocar a palavra resgatar para investir.
Depois, se for necessário, é só usar as funções vlookup ou xlookup na sua planilha.

2 curtidas

Criei uma Calculadora de inflação - IPCA para diversas finalidades. Ainda estou em período de testes para ver se todas as funcionalidades estão ok. Acredito que ela exija a assinatura do Workspace do Google, pois pede o serviço Drive API. Mas não tenho certeza absoluta se a assinatura é estritamente necessária para esse serviço.

Se alguém testar, eu agradeceria muito que trouxesse os resultados, positivos ou negativos. Mas, de forma geral, considero preliminarmente o resultado superior a aplicativos como “Sua perícia” e muito superior a “Calculadora do cidadão”. O ponto é que permite a personalização dos termos para diferentes tipos de contrato, desde o cálculo para reajuste de aluguel, o pagamento de uma parcela ou um título de renda fixa. Vou anexar inicialmente uma imagem da calculadora instalada no Google Sheets:

Se alguém testar nesse minuto, vai dar erro porque o SGS está fora do ar. Apenas um comentário temporal.

A planilha baixa a série 433 do SGS do Banco Central, que é a série IPCA-IBGE padrão, calculada do primeiro ao último dia do mês e divulgado geralmente entre os dias 10 e 15. Outra importação é a planilha de feriados do site da ANBIMA. A vantagem de importar é que se houve modificação, modifica também na planilha. Essa série é a oficial utilizada pelo mercado financeiro para cálculo de dias úteis numa base 252 dias.

Alguns exemplos de uso, antes de eu colocar o código e explicar como instala.

Atualizar um aluguel de R$ 3.000,00 que não sofre reajuste desde 01/01/2023, calculando até 01/01/2024.
Data inicial / final: 01/01/2023 a 01/01/2024
Como tratar o IPCA: IPCA divulgado (sem defasagem)
Período de rateio: Por mês do calendário
Contagem para o IPCA: Dias corridos (o padrão do mercado imobiliário)
Cálculo de frações: Exponencial (mantém a equivalência correta no mês a mês)
Juros adicionais: Sem juros

Título de Renda Fixa
Data inicial / final: 15/05/2023 a Data de hoje
Como tratar o IPCA: Repetir último IPCA
Período de rateio: Entre aniversários mensais
Dia do aniversário: 15
Contagem para o IPCA: Dias úteis (feriados ANBIMA)
Cálculo de frações: Exponencial
Juros adicionais: Adicionar juros
Juros reais: 5,5000
Base dos juros: 252 dias úteis/ano (feriados ANBIMA)

Pagamento de Parcela de Loteamento / Financiamento Imobiliário
Data inicial / final: 10/01/2024 (data base do contrato) a 10/10/2024
Como tratar o IPCA: IPCA divulgado com defasagem
Meses de defasagem: 2 (o ciclo de out/24 usará a inflação de ago/24)
Período de rateio: Entre aniversários mensais
Dia do aniversário: 10
Juros adicionais: Adicionar juros
Juros reais: 12,0000
Base dos juros: 365 dias corridos/ano (ou 360, depende do contrato)

Para instalar, crie uma planilha no Google Sheets, clique em Extensões → Apps Script

Em arquivos, crie 4:
01_Interface.gs
02_Motor.gs
03_ImportarSGS.gs
04_ImportarFERIADOS.gs

Clique em “Serviços +” e adicione Drive API
Depois de colocar os códigos abaixo em cada arquivo, ao lado direito de “Depuração”, selecione instalarCalculadora e depóis “Executar”. Vai pedir as permissões, autorize e deixe instalar a calculadora. Toda a planilha é gerada automaticamente.

01_Interface.gs

/**
 * CALCULADORA DE INFLAÇÃO | 01_Interface.gs | v0.3
 * Execute instalarCalculadora() UMA VEZ após colar os 4 arquivos no Apps Script
 * de uma planilha Google. Reexecutar recria o leiaute da aba Cálculo.
 * Menu INFLAÇÃO: apenas 'Atualizar dados' e 'Limpar cálculo'.
 * Cada edição de campo relevante recalcula automaticamente, sem API externa.
 */

const UI_IPCA = Object.freeze({
  METODOS: {
    'IPCA divulgado (sem defasagem)': 'OFICIAL',
    'IPCA divulgado com defasagem': 'DEFASAGEM',
    'Repetir último IPCA conhecido': 'REPETIR',
    'Usar projeção de inflação': 'PROJECAO'
  },
  FONTES: {
    'ANBIMA — taxa digitada': 'ANBIMA',
    'Focus — taxa digitada': 'FOCUS',
    'Estimativa própria': 'PROPRIA'
  },
  CALENDARIOS: {
    'Por mês do calendário': 'MES_CALENDARIO',
    'Entre aniversários mensais': 'ANIVERSARIO'
  },
  DIAS: {
    'Dias corridos': 'CORRIDOS',
    'Dias úteis (feriados ANBIMA)': 'UTEIS'
  },
  FORMULAS: {
    'Exponencial (recomendado)': 'EXPONENCIAL',
    'Linear': 'LINEAR'
  },
  JUROS: {
    'Sem juros': 'NAO',
    'Adicionar juros': 'SIM'
  },
  BASE_JUROS: {
    '252 dias úteis/ano (feriados ANBIMA)': 'DU252',
    '365 dias corridos/ano': 'DC365',
    '360 dias corridos/ano': 'DC360'
  },
  EDICAO: ['D7', 'D8', 'D9', 'D13', 'D14', 'D15', 'D16', 'D19', 'D20', 'D21', 'D22', 'D25', 'D26', 'D27'],
  PALETA: {
    navy: '#172B45', teal: '#146B72', paper: '#F7F9FC', panel: '#EEF4F6', ink: '#183047',
    blue: '#1557B0', muted: '#596C7B', white: '#FFFFFF', border: '#D8E3E9', amber: '#FFF2D5', red: '#9C2D2D', green: '#1C6554'
  }
});

function onOpen(e) {
  SpreadsheetApp.getUi().createMenu('INFLAÇÃO')
    .addItem('Atualizar dados', 'atualizarDados')
    .addItem('Limpar cálculo', 'limparCalculadora')
    .addToUi();
}

function regraLista_(valores, dica) {
  return SpreadsheetApp.newDataValidation().requireValueInList(valores, true)
    .setAllowInvalid(false).setHelpText(dica).build();
}

function regraNumero_(min, max, dica) {
  return SpreadsheetApp.newDataValidation().requireNumberBetween(min, max)
    .setAllowInvalid(false).setHelpText(dica).build();
}

function montarVisualCalculo_(sh) {
  const C = UI_IPCA.PALETA;
  if (sh.getMaxColumns() < 10) sh.insertColumnsAfter(sh.getMaxColumns(), 10 - sh.getMaxColumns());
  if (sh.getMaxRows() < 160) sh.insertRowsAfter(sh.getMaxRows(), 160 - sh.getMaxRows());
  
  sh.getRange('A1:J160').breakApart();
  sh.clear();
  sh.showRows(1, Math.min(45, sh.getMaxRows()));
  sh.setHiddenGridlines(true);
  sh.setFrozenRows(4);
  sh.setTabColor(C.teal);
  sh.getRange('A1:J160').setBackground(C.paper).setFontFamily('Arial').setFontSize(10).setFontColor(C.ink);
  
  const widths = { 1: 22, 2: 242, 3: 125, 4: 250, 5: 20, 6: 200, 7: 148, 8: 148, 9: 100, 10: 290 };
  Object.keys(widths).forEach(n => sh.setColumnWidth(Number(n), widths[n]));
  
  sh.setRowHeights(1, 160, 26);
  sh.setRowHeight(2, 29);
  sh.setRowHeight(3, 29);
  sh.setRowHeight(4, 42);
  sh.setRowHeight(31, 40);
  
  sh.getRange('B2:I3').merge().setValue('CALCULADORA DE INFLAÇÃO')
    .setBackground(C.navy).setFontColor(C.white).setFontSize(17).setFontWeight('bold')
    .setVerticalAlignment('middle');
    
  sh.getRange('B4:I4').merge().setValue('IPCA + juros reais | preencha as células azuis; os resultados são automáticos.')
    .setBackground(C.panel).setFontColor(C.muted).setWrap(true).setVerticalAlignment('middle');

  function secao(linha, texto) {
    sh.getRange('B' + linha + ':I' + linha).merge().setValue(texto).setFontWeight('bold')
      .setFontColor(C.white).setBackground(C.teal).setVerticalAlignment('middle');
    sh.setRowHeight(linha, 31);
  }

  secao(6, '01   PERÍODO E VALOR');
  secao(11, '02   COMO CALCULAR A INFLAÇÃO');
  secao(18, '03   COMO DISTRIBUIR O ÍNDICE NOS DIAS');
  secao(24, '04   JUROS REAIS (OPCIONAL)');
  secao(30, '05   RESULTADO');
  secao(43, '06   MEMÓRIA DE CÁLCULO POR CICLO');

  const linhas = {
    7: 'Valor inicial (R$)', 8: 'Data inicial', 9: 'Data final', 12: 'Índice',
    13: 'Como tratar o IPCA?', 14: 'Meses de defasagem', 15: 'Origem da projeção', 16: 'Taxa prevista (% a.m.)',
    19: 'Período de rateio', 20: 'Dia do aniversário', 21: 'Contagem para o IPCA', 22: 'Cálculo de frações de mês',
    25: 'Juros adicionais?', 26: 'Juros reais (% a.a.)', 27: 'Base dos juros',
    33: 'Correção monetária', 34: 'Juros remuneratórios', 35: 'Valor final', 36: 'IPCA acumulado',
    37: 'Rentabilidade acumulada', 38: 'Fator IPCA', 39: 'Fator dos juros', 40: 'Dias transcorridos'
  };

  Object.keys(linhas).forEach(r => {
    sh.getRange('B' + r + ':C' + r).merge().setValue(linhas[r]).setFontWeight('bold').setVerticalAlignment('middle');
  });

  const ajudas = {
    7: 'Digite o principal a atualizar, em reais.',
    8: 'O dia inicial não é contado. Ex.: 05 a 21 = 16 dias corridos.',
    9: 'O dia final é contado. Permite datas no mesmo mês.',
    12: 'Nesta versão, usamos apenas o IPCA mensal (SGS 433).',
    13: 'Escolha UMA alternativa. Não existe combinação de projeção com defasagem.',
    14: 'Ex.: outubro com 2 meses de defasagem usa o IPCA de agosto.',
    15: 'A instituição identifica a ORIGEM INFORMADA; esta versão não baixa projeções.',
    16: 'Digite 0,50 para 0,50% ao mês. A taxa se repete nos meses sem índice oficial.',
    19: 'Mês do calendário: do dia 1 ao dia 1 seguinte. Aniversário: de uma data mensal até a seguinte.',
    20: 'Ex.: aniversário 21 = ciclos 21/set a 21/out, 21/out a 21/nov etc.',
    21: 'Dias úteis excluem sábados, domingos e feriados bancários nacionais do calendário ANBIMA, quando atualizado.',
    22: 'Exponencial preserva a composição entre trechos. Linear é alternativa opcional.',
    25: 'IPCA sem juros ou IPCA acrescido de juros reais compostos.',
    26: 'Digite 6 para IPCA + 6% ao ano. Não digite 0,06.',
    27: '252 é uma convenção anual fixa. Dias úteis descontam sábados, domingos e feriados ANBIMA; 365/360 são dias corridos.',
    33: 'Variação do principal causada apenas pela inflação.',
    34: 'Juros calculados sobre o principal corrigido, em regime composto.',
    35: 'Principal + inflação + juros.',
    36: 'IPCA acumulado considerando o pró-rata selecionado.',
    37: 'Variação total sobre o principal, incluindo inflação e juros.',
    38: 'Produto dos fatores de atualização monetária.',
    39: 'Fator de juros reais.',
    40: 'Dias corridos e dias úteis, já descontados os feriados ANBIMA quando a base está carregada.'
  };

  Object.keys(ajudas).forEach(r => {
    sh.getRange('F' + r + ':I' + r).merge().setValue(ajudas[r])
      .setFontSize(9).setFontColor(C.muted).setWrap(true).setVerticalAlignment('middle');
    sh.setRowHeight(Number(r), 37);
  });

  const editaveis = UI_IPCA.EDICAO;
  editaveis.forEach(a1 => sh.getRange(a1).setBackground('#E5F0FE')
    .setFontColor(C.blue).setFontWeight('bold').setVerticalAlignment('middle')
    .setBorder(true, true, true, true, false, false, C.border, SpreadsheetApp.BorderStyle.SOLID));

  sh.getRange('D12').setValue('IPCA').setBackground(C.panel).setFontColor(C.ink).setFontWeight('bold');
  sh.getRange('D7').setNumberFormat('"R$" #,##0.00;[Red]("R$" #,##0.00);"-"');
  sh.getRangeList(['D8', 'D9']).setNumberFormat('dd/MM/yyyy');
  sh.getRangeList(['D14', 'D20']).setNumberFormat('0');
  sh.getRangeList(['D16', 'D26']).setNumberFormat('0.0000');

  sh.getRange('D13').setDataValidation(regraLista_(Object.keys(UI_IPCA.METODOS), 'Selecione apenas um método para obter a inflação.'));
  sh.getRange('D14').setDataValidation(regraNumero_(1, 24, 'Número inteiro de meses: 1 a 24.'));
  sh.getRange('D15').setDataValidation(regraLista_(Object.keys(UI_IPCA.FONTES), 'Origem declarada da taxa digitada.'));
  sh.getRange('D16').setDataValidation(regraNumero_(-99.999, 1000, 'Em pontos percentuais: 0,50 representa 0,50% a.m.'));
  sh.getRange('D19').setDataValidation(regraLista_(Object.keys(UI_IPCA.CALENDARIOS), 'Como dividir os ciclos mensais.'));
  sh.getRange('D20').setDataValidation(regraNumero_(1, 31, 'Dia do mês (1 a 31), ajustado ao último dia em meses curtos.'));
  sh.getRange('D21').setDataValidation(regraLista_(Object.keys(UI_IPCA.DIAS), 'Exclui sábados, domingos e feriados bancários nacionais ANBIMA. Requer aba Feriados atualizada.'));
  sh.getRange('D22').setDataValidation(regraLista_(Object.keys(UI_IPCA.FORMULAS), 'Como obter o fator pró-rata.'));
  sh.getRange('D25').setDataValidation(regraLista_(Object.keys(UI_IPCA.JUROS), 'Ativar/desativar juros reais.'));
  sh.getRange('D26').setDataValidation(regraNumero_(-99.99, 1000, '6 = seis por cento ao ano.'));
  sh.getRange('D27').setDataValidation(regraLista_(Object.keys(UI_IPCA.BASE_JUROS), 'Base anual para capitalização dos juros.'));
  sh.getRange('D8').setDataValidation(SpreadsheetApp.newDataValidation().requireDate().setAllowInvalid(false).build());
  sh.getRange('D9').setDataValidation(SpreadsheetApp.newDataValidation().requireDate().setAllowInvalid(false).build());
  sh.getRange('D7').setDataValidation(SpreadsheetApp.newDataValidation().requireNumberGreaterThanOrEqualTo(0).setAllowInvalid(false).build());

  sh.getRange('B31:I31').merge().setBackground(C.panel).setValue('Informe o valor e as duas datas.')
    .setFontColor(C.muted).setWrap(true).setVerticalAlignment('middle').setFontWeight('bold');

  sh.getRange('D33:D35').setNumberFormat('"R$" #,##0.00;[Red]("R$" #,##0.00);"-"');
  sh.getRange('D35').setBackground('#E3F1EA').setFontSize(14).setFontWeight('bold');
  sh.getRange('D36:D37').setNumberFormat('0.000000"%";[Red](0.000000"%");–');
  sh.getRange('D38:D39').setNumberFormat('0.00000000');
  sh.getRange('D33:D40').setFontWeight('bold');
  
  sh.getRange('F43:I43').setFontColor(C.white);
  sh.getRange('B44:J44').merge().setValue('O detalhamento mostra o IPCA aplicado a cada trecho e sua origem.')
    .setFontColor(C.muted).setFontSize(9).setBackground(C.paper);
    
  sh.getRange('B45:D45').setValues([['Trecho considerado', 'Competência', 'Origem da taxa']]);
  sh.getRange('F45:J45').setValues([['Taxa mês (%)', 'Dias / ciclo', 'Fator trecho', 'Fator acumulado', 'Mês de origem']]);
  sh.getRange('B45:J45').setBackground(C.navy).setFontColor(C.white)
    .setFontWeight('bold').setWrap(true).setVerticalAlignment('middle');
  sh.setRowHeight(45, 38);
  
  sh.getRange('F46:F160').setNumberFormat('0.0000"%"');
  sh.getRange('H46:I160').setNumberFormat('0.00000000');
  sh.getRange('J2').setValue('IPCA_V02').setFontColor(C.paper).setFontSize(7);
  sh.setActiveRange(sh.getRange('D7'));
}

function montarVisualSeries_(sh) {
  const C = UI_IPCA.PALETA;
  if (sh.getMaxColumns() < 8) sh.insertColumnsAfter(sh.getMaxColumns(), 8 - sh.getMaxColumns());
  if (sh.getMaxRows() < 1000) sh.insertRowsAfter(sh.getMaxRows(), 1000 - sh.getMaxRows());
  
  sh.setHiddenGridlines(true); 
  sh.setFrozenRows(5); 
  sh.setTabColor('#64748B');
  
  const largura = [142, 170, 180, 185, 260, 350, 26, 260];
  largura.forEach((w, i) => sh.setColumnWidth(i + 1, w));
  
  sh.getRange('A1:H5').breakApart();
  sh.getRange('A1:H2').setBackground(C.navy).setFontColor(C.white);
  sh.getRange('A1:F2').merge().setValue('SÉRIES  |  IPCA MENSAL — BANCO CENTRAL')
    .setFontSize(14).setFontWeight('bold').setVerticalAlignment('middle');
  sh.getRange('A3:F3').merge().setValue('Valores em % ao mês: 0,48 representa 0,48% — não 48%.')
    .setBackground(C.panel).setFontColor(C.muted).setFontSize(10);
  sh.getRange('A4:F4').merge().setValue('A data de importação não comprova quando o IPCA foi publicado.')
    .setFontColor(C.muted).setFontSize(9);
  sh.getRange('A5:F5').setValues([['Competência', 'IPCA (% ao mês)', 'Fonte', 'Importado em', 'Endpoint SGS', 'Observação']])
    .setBackground(C.teal).setFontColor(C.white).setFontWeight('bold');
    
  sh.getRange('A6:A1000').setNumberFormat('@');
  sh.getRange('B6:B1000').setNumberFormat('0.00"%"');
  sh.getRange('D6:D1000').setNumberFormat('dd/MM/yyyy HH:mm');
  
  if (sh.getLastRow() < 6) {
    sh.getRange('H3').setValue('Ainda não atualizado').setFontColor(C.muted);
    sh.getRange('H4').setValue('Use INFLAÇÃO → Atualizar dados').setFontColor(C.muted).setWrap(true);
  }
  sh.setRowHeight(5, 30);
}

/** Layout independente: nunca apaga a tabela importada A6:D. */
function montarVisualFeriados_(sh) {
  const C = UI_IPCA.PALETA;
  if (sh.getMaxColumns() < 8) sh.insertColumnsAfter(sh.getMaxColumns(), 8 - sh.getMaxColumns());
  if (sh.getMaxRows() < 1100) sh.insertRowsAfter(sh.getMaxRows(), 1100 - sh.getMaxRows());
  sh.setHiddenGridlines(true);
  sh.setFrozenRows(5);
  sh.setTabColor(C.teal);
  [142, 164, 290, 155, 190, 330, 22, 310].forEach((w, i) => sh.setColumnWidth(i + 1, w));
  sh.getRange('A1:H5').breakApart();
  sh.getRange('A1:H2').setBackground(C.navy).setFontColor(C.white);
  sh.getRange('A1:F2').merge().setValue('FERIADOS  |  CALENDÁRIO BANCÁRIO — ANBIMA')
    .setFontSize(14).setFontWeight('bold').setVerticalAlignment('middle');
  sh.getRange('A3:F3').merge().setValue('Dias úteis: excluem sábados, domingos e feriados bancários nacionais da ANBIMA.')
    .setBackground(C.panel).setFontColor(C.muted).setFontSize(10);
  sh.getRange('A4:F4').merge().setValue('Fonte: anbima.com.br/feriados/ | Não inclui feriados municipais nem todas as exceções operacionais.')
    .setFontColor(C.muted).setFontSize(9);
  sh.getRange('A5:F5').setValues([['Data (AAAA-MM-DD)', 'Dia da semana', 'Feriado', 'Fonte', 'Importado em', 'Observação']])
    .setBackground(C.teal).setFontColor(C.white).setFontWeight('bold');
  sh.getRange('A6:A' + sh.getMaxRows()).setNumberFormat('@');
  sh.getRange('E6:E' + sh.getMaxRows()).setNumberFormat('dd/MM/yyyy HH:mm');
  sh.setRowHeight(5, 31);
  if (sh.getRange('H3').isBlank()) sh.getRange('H3').setValue('Ainda não atualizado').setFontColor(C.muted);
  if (sh.getRange('H4').isBlank()) sh.getRange('H4').setValue('Use INFLAÇÃO → Atualizar dados').setFontColor(C.muted).setWrap(true);
}

/** Único comando do menu para atualizar SGS e ANBIMA, independentemente. */
function atualizarDados() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const lock = LockService.getDocumentLock();
  if (!lock || !lock.tryLock(20000)) {
    ss.toast('Já existe uma atualização em andamento.', 'Atualizar dados', 6);
    return;
  }
  const ok = [], falhas = [];
  try {
    try {
      ss.toast('Consultando SGS 433...', 'Atualizar dados', 6);
      const r = atualizarSerieIPCA_();
      ok.push('IPCA: ' + r.total + ' competências');
    } catch (e) { falhas.push('IPCA: ' + e.message); }
    try {
      ss.toast('Importando calendário ANBIMA...', 'Atualizar dados', 6);
      const r = atualizarFeriadosANBIMA_();
      ok.push('Feriados: ' + r.total + ' datas');
    } catch (e) { falhas.push('Feriados: ' + e.message); }
    recalcularSemAviso();
    ss.toast(ok.concat(falhas).join(' | '), falhas.length ? 'Atualização parcial' : 'Dados atualizados', 10);
    if (falhas.length) SpreadsheetApp.getUi().alert(
      'Atualização ' + (ok.length ? 'parcial' : 'não concluída'),
      'Atualizados: ' + (ok.join('; ') || 'nenhum') + '\n\nFalhas:\n' + falhas.join('\n') +
      '\n\nOs dados anteriores de cada origem com falha foram preservados.',
      SpreadsheetApp.getUi().ButtonSet.OK);
  } finally { lock.releaseLock(); }
}

function instalarCalculadora() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  if (!ss.getSheetByName('Cálculo') && ss.getSheets().length === 1 && ss.getSheets()[0].getLastRow() === 0) {
    ss.getSheets()[0].setName('Cálculo');
  }
  
  const c = ss.getSheetByName('Cálculo') || ss.insertSheet('Cálculo');
  const s = ss.getSheetByName('Séries') || ss.insertSheet('Séries');
  const h = ss.getSheetByName('Feriados') || ss.insertSheet('Feriados');
  
  const jaInstalado = String(c.getRange('J2').getValue()) === 'IPCA_V02';
  const valores = jaInstalado ? Object.fromEntries(UI_IPCA.EDICAO.map(a1 => [a1, c.getRange(a1).getValue()])) : {};
  
  montarVisualCalculo_(c);
  
  const padrao = {
    D13: 'IPCA divulgado (sem defasagem)', D19: 'Por mês do calendário',
    D21: 'Dias corridos', D22: 'Exponencial (recomendado)', D25: 'Sem juros',
    D27: '365 dias corridos/ano'
  };
  
  Object.keys(padrao).forEach(a1 => c.getRange(a1).setValue(padrao[a1]));
  
  if (jaInstalado) {
    // Ajusta rótulos legados, antes da reposição dos campos editados.
    if (valores.D21 === 'Dias úteis (sem feriados)') valores.D21 = 'Dias úteis (feriados ANBIMA)';
    if (valores.D27 === '252 dias úteis/ano (sem feriados)') valores.D27 = '252 dias úteis/ano (feriados ANBIMA)';
    Object.keys(valores).forEach(a1 => {
      if (valores[a1] !== '' && valores[a1] !== null && a1 !== 'D12') {
        c.getRange(a1).setValue(valores[a1]);
      }
    });
  }
  
  montarVisualSeries_(s);
  montarVisualFeriados_(h);
  ajustarCamposCalculo_(c, false);
  onOpen();
  ss.setActiveSheet(c);
  recalcularSemAviso();
  ss.toast('Interface instalada. Use INFLAÇÃO → Atualizar dados.', 'Calculadora', 8);
}

function ajustarCamposCalculo_(sh, aposEdicao) {
  const modo = sh.getRange('D13').getValue();
  const cal = sh.getRange('D19').getValue();
  const juros = sh.getRange('D25').getValue();
  const eDef = modo === 'IPCA divulgado com defasagem';
  const eProj = modo === 'Usar projeção de inflação';
  
  [[14, eDef], [15, eProj], [16, eProj], [20, cal === 'Entre aniversários mensais'], [26, juros === 'Adicionar juros'], [27, juros === 'Adicionar juros']]
    .forEach(([r, visivel]) => {
      if (visivel) sh.showRows(r); else sh.hideRows(r);
    });
    
  if (eDef && (sh.getRange('D14').getValue() === '' || sh.getRange('D14').getValue() === null)) sh.getRange('D14').setValue(2);
  if (eProj && !sh.getRange('D15').getValue()) sh.getRange('D15').setValue('Estimativa própria');
  
  if (cal === 'Entre aniversários mensais' && !sh.getRange('D20').getValue()) {
    const inicial = sh.getRange('D8').getValue();
    const tz = SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone();
    sh.getRange('D20').setValue(inicial instanceof Date ? Number(Utilities.formatDate(inicial, tz, 'd')) : 15);
  }
  if (juros === 'Adicionar juros' && sh.getRange('D26').getValue() === '') sh.getRange('D26').setValue(6);
}

function onEdit(e) {
  if (!e || !e.range || e.range.getSheet().getName() !== 'Cálculo') return;
  const r = e.range;
  if (r.getColumn() > 4 || r.getLastColumn() < 4 || r.getRow() > 27 || r.getLastRow() < 7) return;
  
  const sh = r.getSheet();
  const mudouModo = r.getRow() <= 13 && r.getLastRow() >= 13;
  
  if (mudouModo) sh.getRangeList(['D14', 'D15', 'D16']).clearContent();
  if (r.getRow() <= 15 && r.getLastRow() >= 15 && !mudouModo) sh.getRange('D16').clearContent();
  if (r.getRow() <= 19 && r.getLastRow() >= 19) sh.getRange('D20').clearContent();
  if (r.getRow() <= 25 && r.getLastRow() >= 25) sh.getRange('D26').clearContent();
  
  ajustarCamposCalculo_(sh, true);
  recalcularSemAviso();
}

function limparResultados_(sh) {
  sh.getRange('D33:D40').clearContent();
  const ultima = Math.min(Math.max(sh.getLastRow(), 45), sh.getMaxRows());
  if (ultima >= 46) sh.getRange(46, 2, ultima - 45, 9).clearContent();
}

function escreverStatus_(sh, texto, erro) {
  const cel = sh.getRange('B31');
  cel.setValue(texto).setBackground(erro ? '#FFF0EA' : UI_IPCA.PALETA.panel)
    .setFontColor(erro ? UI_IPCA.PALETA.red : UI_IPCA.PALETA.muted);
}

function dataEntradaISO_(valor, tz) {
  if (!(valor instanceof Date) || Number.isNaN(valor.getTime())) throw Error('As datas precisam ser datas válidas no Google Sheets.');
  return Utilities.formatDate(valor, tz, 'yyyy-MM-dd');
}

function parametrosDaInterface_(sh, ss) {
  const get = a1 => sh.getRange(a1).getValue();
  const modo = UI_IPCA.METODOS[get('D13')];
  if (!modo) throw Error('Escolha um dos métodos de inflação no campo azul.');
  
  const proj = modo === 'PROJECAO';
  if (proj && get('D16') === '') throw Error('Digite a projeção mensal em D16 (0,50 significa 0,50%).');
  
  const fonte = proj ? UI_IPCA.FONTES[get('D15')] : null;
  if (proj && !fonte) throw Error('Selecione a origem da taxa projetada.');
  
  const comJuros = get('D25') === 'Adicionar juros';
  const base = UI_IPCA.BASE_JUROS[comJuros ? get('D27') : '365 dias corridos/ano'];
  
  // O calendário é opcional em cálculos só com dias corridos.
  // Em DU/252 ou rateio por dias úteis, o motor exigirá calendário completo.
  const calendarioFeriados = lerFeriadosANBIMA_();
  const p = {
    valorInicial: get('D7'),
    inicio: dataEntradaISO_(get('D8'), ss.getSpreadsheetTimeZone()),
    fim: dataEntradaISO_(get('D9'), ss.getSpreadsheetTimeZone()),
    metodo: modo,
    mesesDefasagem: modo === 'DEFASAGEM' ? get('D14') : 0,
    fonteProjecao: fonte,
    taxaProjetadaPct: proj ? get('D16') : undefined,
    calendario: UI_IPCA.CALENDARIOS[get('D19')],
    diaAniversario: get('D19') === 'Entre aniversários mensais' ? get('D20') : 1,
    baseInflacao: UI_IPCA.DIAS[get('D21')],
    formulaInflacao: UI_IPCA.FORMULAS[get('D22')],
    jurosPctAA: comJuros ? get('D26') : 0,
    baseJuros: base,
    feriados: calendarioFeriados.datas,
    coberturaFeriados: calendarioFeriados.cobertura
  };
  
  if (typeof p.valorInicial !== 'number') throw Error('Informe o valor inicial em reais.');
  if (!p.calendario || !p.baseInflacao || !p.formulaInflacao || !p.baseJuros) throw Error('Complete as opções de rateio e juros.');
  return p;
}

/** Chamado pelo onEdit e após atualização da série; não mostra pop-up durante edição. */
function recalcularSemAviso() {
  const ss = SpreadsheetApp.getActiveSpreadsheet(), sh = ss.getSheetByName('Cálculo');
  if (!sh) return;
  limparResultados_(sh);
  
  if (sh.getRange('D7').getValue() === '' || !sh.getRange('D8').getValue() || !sh.getRange('D9').getValue()) {
    escreverStatus_(sh, 'Preencha valor inicial, data inicial e data final. O cálculo é automático.', false);
    return;
  }
  
  try {
    const params = parametrosDaInterface_(sh, ss);
    const registros = lerSerieIPCA_();
    if (registros.length === 0) throw Error('A aba Séries está vazia. Use INFLAÇÃO → Atualizar dados.');
    
    const resultado = MotorIPCA.calcular(params, registros);
    sh.getRange('D33:D40').setValues([
      [resultado.correcaoMonetaria], [resultado.jurosRemuneratorios], [resultado.valorFinal],
      [resultado.ipcaPct], [resultado.retornoTotalPct], [resultado.fatorIPCA],
      [resultado.fatorJuros], [resultado.diasCorridos + ' corridos / ' + (resultado.diasUteis === null ? '— (atualize Feriados)' : resultado.diasUteis) + ' úteis']
    ]);
    
    if (resultado.memoria.length > 0) {
      if (sh.getMaxRows() < 45 + resultado.memoria.length) sh.insertRowsAfter(sh.getMaxRows(), 45 + resultado.memoria.length - sh.getMaxRows());
      const memoriaEsquerda = resultado.memoria.map(x => [x.inicio + ' a ' + x.fim, x.competencia, x.tipo]);
      const memoriaDireita = resultado.memoria.map(x => [x.taxaPct, x.dias + '/' + x.diasCiclo, x.fator, x.fatorAcumulado, x.mesOrigem]);
      
      sh.getRange(46, 2, memoriaEsquerda.length, 3).setValues(memoriaEsquerda).setFontSize(9);
      sh.getRange(46, 6, memoriaDireita.length, 5).setValues(memoriaDireita).setFontSize(9);
      sh.getRange(46, 2, resultado.memoria.length, 9).setBackground('#FFFFFF');
      sh.getRange(46, 6, resultado.memoria.length, 1).setNumberFormat('0.0000"%"');
      sh.getRange(46, 8, resultado.memoria.length, 2).setNumberFormat('0.00000000');
    }
    
    const hipotese = resultado.diasUteis === null ? ' Calendário bancário ainda não disponível para informar dias úteis.' : '';
    const proj = resultado.memoria.some(x => x.tipo.indexOf('PROJEÇÃO') >= 0);
    const repet = resultado.memoria.some(x => x.tipo.indexOf('REPETIDO') >= 0);
    const fonte = proj ? ' | inclui taxa projetada DIGITADA' : (repet ? ' | inclui repetição de IPCA' : ' | somente IPCA oficial');
    
    escreverStatus_(sh, 'Calculado: ' + resultado.memoria.length + ' trecho(s)' + fonte + '.' + hipotese, false);
  } catch (erro) {
    escreverStatus_(sh, 'Não foi possível calcular: ' + erro.message, true);
  }
}

/** Limpa apenas inputs e resultado; a aba Séries permanece intacta. */
function limparCalculadora() {
  const ui = SpreadsheetApp.getUi();
  if (ui.alert('Limpar cálculo', 'Limpar campos e resultados? Séries e Feriados serão mantidos.', ui.ButtonSet.YES_NO) !== ui.Button.YES) return;
  const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Cálculo');
  if (!sh) return;
  
  sh.getRangeList(UI_IPCA.EDICAO).clearContent();
  const padrao = {
    D13: 'IPCA divulgado (sem defasagem)', D19: 'Por mês do calendário',
    D21: 'Dias corridos', D22: 'Exponencial (recomendado)', D25: 'Sem juros', D27: '365 dias corridos/ano'
  };
  Object.keys(padrao).forEach(a1 => sh.getRange(a1).setValue(padrao[a1]));
  
  ajustarCamposCalculo_(sh, false);
  recalcularSemAviso();
  sh.activate(); 
  sh.getRange('D7').activate();
}
1 curtida

02_Motor.gs

/**
 * CALCULADORA DE INFLAÇÃO | 02_Motor.gs | v0.3
 * Motor puro: não lê/escreve planilhas e não faz solicitações externas.
 * Taxas mensais em PONTOS PERCENTUAIS: 0.50 = 0,50%; juros 6 = 6% a.a.
 * Periodicidade: (data inicial, data final], evitando dupla contagem.
 * Modo ANIVERSARIO: referência mensal = mês do INÍCIO de cada ciclo.
 * Modo MES_CALENDARIO: referência mensal = mês do calendário corrente.
 * IMPORTANTE: indisponibilidade histórica 'as of date' não é reproduzível
 * apenas com o SGS, que não fornece datas de divulgação.
 */
const MotorIPCA = (function () {
  const DIA = 86400000;
  function exigir(condicao, mensagem) { if (!condicao) throw Error(mensagem); }
  function data(iso) {
    exigir(/^\d{4}-\d{2}-\d{2}$/.test(iso || ''), 'Data inválida: ' + iso);
    const [a,m,d] = iso.split('-').map(Number), x = new Date(Date.UTC(a,m-1,d));
    exigir(x.getUTCFullYear() === a && x.getUTCMonth() === m-1 && x.getUTCDate() === d, 'Data inexistente: ' + iso);
    return x;
  }
  function iso(x) { return x.toISOString().slice(0,10); }
  function mes(x) { return iso(x).slice(0,7); }
  function inicioMes(x) { return new Date(Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), 1)); }
  function somaMes(x, d) { return new Date(Date.UTC(x.getUTCFullYear(), x.getUTCMonth()+d, 1)); }
  function diasMes(a,m) { return new Date(Date.UTC(a,m,0)).getUTCDate(); }
  function aniversario(a,m,d) { return new Date(Date.UTC(a,m-1, Math.min(d, diasMes(a,m)))); }
  function mesAtras(competencia, n) { return mes(somaMes(data(competencia+'-01'),-n)); }
  function dias(a,b) { return Math.round((b-a)/DIA); }
  function addDia(x,n) { return new Date(x.getTime()+n*DIA); }
  function uteis(a,b,feriados) {
    let n=0;
    for (let x=addDia(a,1); x<=b; x=addDia(x,1)) {
      if (x.getUTCDay()!==0 && x.getUTCDay()!==6 && !feriados.has(iso(x))) n++;
    }
    return n;
  }
  function ciclo(x,calendario,diaAniversario) {
    if (calendario === 'MES_CALENDARIO') {
      const a=inicioMes(x); return {inicio:a,fim:somaMes(a,1)};
    }
    exigir(calendario === 'ANIVERSARIO', 'Período de rateio inválido.');
    exigir(Number.isInteger(diaAniversario) && diaAniversario>=1 && diaAniversario<=31, 'Dia de aniversário deve ser de 1 a 31.');
    const a=x.getUTCFullYear(),m=x.getUTCMonth()+1,aniv=aniversario(a,m,diaAniversario);
    if(x>=aniv) {
      const prox=somaMes(aniv,1);
      return {inicio:aniv,fim:aniversario(prox.getUTCFullYear(),prox.getUTCMonth()+1,diaAniversario)};
    }
    const anterior=somaMes(aniv,-1);
    return {inicio:aniversario(anterior.getUTCFullYear(),anterior.getUTCMonth()+1,diaAniversario),fim:aniv};
  }
  function numero(n, nome) {
    exigir(typeof n === 'number' && Number.isFinite(n), nome+' deve ser um número válido.');
    return n;
  }
  function fator(taxaPct, fracao, formula) {
    numero(taxaPct,'IPCA');
    exigir(taxaPct>-100, 'A inflação mensal não pode ser inferior ou igual a -100%.');
    if(formula==='EXPONENCIAL') return Math.pow(1+taxaPct/100,fracao);
    if(formula==='LINEAR') return 1+(taxaPct/100)*fracao;
    throw Error('Método pró-rata inválido.');
  }
  function prepararSerie(registros) {
    const mapa=new Map();
    (registros||[]).forEach(r=>{
      if (!r || !/^\d{4}-\d{2}$/.test(r.competencia || '')) return;
      numero(r.taxaPct,'Taxa SGS');
      exigir(r.taxaPct>-100,'Série contém IPCA inválido em '+r.competencia);
      exigir(!mapa.has(r.competencia),'Série possui competência duplicada: '+r.competencia);
      mapa.set(r.competencia, {taxaPct:r.taxaPct,fonte:'BCB SGS 433'});
    });
    return mapa;
  }
  function selecionarTaxa(competencia, tipo, mapa, p) {
    if(tipo==='OFICIAL' || tipo==='DEFASAGEM') {
      exigir(mapa.has(competencia), 'IPCA oficial de '+competencia+' não encontrado. Atualize a aba Séries ou altere o método.');
      return {taxaPct:mapa.get(competencia).taxaPct, origem:'IPCA OFICIAL', mesOrigem:competencia};
    }
    // Projeção e repetição são alternativas de substituição apenas dos meses indisponíveis.
    if(mapa.has(competencia)) {
      return {taxaPct:mapa.get(competencia).taxaPct,origem:'IPCA OFICIAL',mesOrigem:competencia};
    }
    if(tipo==='REPETIR') {
      const anteriores=Array.from(mapa.keys()).filter(k=>k<=competencia).sort();
      exigir(anteriores.length>0,'Nenhum IPCA anterior publicado para repetir em '+competencia+'.');
      const origem=anteriores[anteriores.length-1];
      return {taxaPct:mapa.get(origem).taxaPct,origem:'ÚLTIMO IPCA REPETIDO',mesOrigem:origem};
    }
    if(tipo==='PROJECAO') {
      numero(p.taxaProjetadaPct, 'Projeção mensal');
      exigir(p.taxaProjetadaPct>-100,'Projeção mensal não pode ser <= -100%.');
      exigir(['ANBIMA','FOCUS','PROPRIA'].includes(p.fonteProjecao),'Selecione a origem da projeção.');
      const nome={ANBIMA:'ANBIMA (taxa digitada)',FOCUS:'FOCUS (taxa digitada)',PROPRIA:'ESTIMATIVA PRÓPRIA'}[p.fonteProjecao];
      return {taxaPct:p.taxaProjetadaPct,origem:'PROJEÇÃO — '+nome,mesOrigem:competencia};
    }
    throw Error('Regra de inflação inválida.');
  }
  /**
   * Parâmetros:
   * valorInicial:number, inicio/fim:'YYYY-MM-DD', metodo:'OFICIAL'|'DEFASAGEM'|'REPETIR'|'PROJECAO',
   * mesesDefasagem:1..24 (somente DEFASAGEM), fonteProjecao:'ANBIMA'|'FOCUS'|'PROPRIA',
   * taxaProjetadaPct:number (somente PROJECAO, aplicada em CADA mês ainda sem taxa oficial),
   * calendario:'MES_CALENDARIO'|'ANIVERSARIO', diaAniversario:1..31,
   * baseInflacao:'CORRIDOS'|'UTEIS', formulaInflacao:'EXPONENCIAL'|'LINEAR',
   * jurosPctAA:number, baseJuros:'DU252'|'DC365'|'DC360', feriados:['YYYY-MM-DD'],
   * coberturaFeriados:{inicio:'YYYY-MM-DD', fim:'YYYY-MM-DD'} ou null.
   * Em UTEIS/DU252 é OBRIGATÓRIO informar feriados ANBIMA e sua cobertura.
   * Sem feriados, cálculos DC são aceitos, mas diasUteis=null no resultado.
   */
  function calcular(p,registros) {
    exigir(p && typeof p==='object','Parâmetros ausentes.');
    numero(p.valorInicial,'Valor inicial'); exigir(p.valorInicial>=0,'Valor inicial não pode ser negativo.');
    const ini=data(p.inicio), fim=data(p.fim); exigir(ini<=fim,'Data final anterior à data inicial.');
    exigir(['OFICIAL','DEFASAGEM','REPETIR','PROJECAO'].includes(p.metodo),'Escolha uma regra de inflação.');
    const lag=p.metodo==='DEFASAGEM'?p.mesesDefasagem:0;
    exigir(Number.isInteger(lag) && lag>=0 && lag<=24,'Defasagem deve ser um número inteiro de 1 a 24.');
    if(p.metodo==='DEFASAGEM') exigir(lag>=1,'Informe de 1 a 24 meses de defasagem.');
    exigir(['CORRIDOS','UTEIS'].includes(p.baseInflacao),'Escolha o critério de contagem de dias.');
    exigir(['EXPONENCIAL','LINEAR'].includes(p.formulaInflacao),'Escolha a fórmula pró-rata.');
    const m=prepararSerie(registros);
    exigir(['DU252','DC365','DC360'].includes(p.baseJuros),'Base de juros inválida.');
    const calendarioCompleto = Array.isArray(p.feriados) && p.feriados.length >= 500 &&
      p.coberturaFeriados && typeof p.coberturaFeriados === 'object';
    const feriados = new Set();
    if (calendarioCompleto) {
      p.feriados.forEach(d => {
        data(d);
        exigir(!feriados.has(d),'Calendário ANBIMA contém data duplicada: '+d);
        feriados.add(d);
      });
    }
    const exigeUteis = p.baseInflacao === 'UTEIS' || p.baseJuros === 'DU252';
    if (exigeUteis) exigir(calendarioCompleto,
      'Calendário de feriados ANBIMA não disponível. Use INFLAÇÃO → Atualizar dados.');
    function conferirCobertura(a,b) {
      exigir(calendarioCompleto,
        'Calendário ANBIMA ausente. Atualize os dados antes de usar dias úteis.');
      exigir(iso(a) >= p.coberturaFeriados.inicio && iso(b) <= p.coberturaFeriados.fim,
        'Período fora da cobertura do calendário ANBIMA (' +
        p.coberturaFeriados.inicio + ' a ' + p.coberturaFeriados.fim + ').');
    }
    if (p.baseJuros === 'DU252') conferirCobertura(ini,fim);
    let atual=ini, F=1;
    const memoria=[];
    let contador=0;
    while(atual<fim) {
      exigir(++contador<=1200,'Período excede o limite de 1.200 meses do protótipo.');
      const ref=ciclo(atual,p.calendario,p.diaAniversario);
      const parteFim=new Date(Math.min(fim.getTime(),ref.fim.getTime()));
      if (p.baseInflacao==='UTEIS') conferirCobertura(ref.inicio,ref.fim);
      const d=p.baseInflacao==='UTEIS'?uteis(atual,parteFim,feriados):dias(atual,parteFim);
      const D=p.baseInflacao==='UTEIS'?uteis(ref.inicio,ref.fim,feriados):dias(ref.inicio,ref.fim);
      exigir(D>0,'O ciclo de rateio não tem dias válidos.');
      const referencia=mesAtras(mes(ref.inicio),lag);
      const taxa=selecionarTaxa(referencia,p.metodo,m,p), parcela=fator(taxa.taxaPct,d/D,p.formulaInflacao);
      exigir(parcela>0 && Number.isFinite(parcela),'Fator parcial inválido.');
      F*=parcela;
      exigir(Number.isFinite(F) && F>0,'Fator acumulado inválido.');
      memoria.push({inicio:iso(atual),fim:iso(parteFim),competencia:referencia,mesOrigem:taxa.mesOrigem,
        tipo:taxa.origem,taxaPct:taxa.taxaPct,dias:d,diasCiclo:D,fracao:d/D,
        fator:parcela,fatorAcumulado:F,cicloInicio:iso(ref.inicio),cicloFim:iso(ref.fim)});
      atual=parteFim;
    }
    const dc=dias(ini,fim);
    const diasUteisCobertos = calendarioCompleto &&
      iso(ini) >= p.coberturaFeriados.inicio && iso(fim) <= p.coberturaFeriados.fim;
    const du=diasUteisCobertos ? uteis(ini,fim,feriados) : null;
    const juros=numero(p.jurosPctAA,'Taxa anual de juros');
    exigir(juros>-100,'Juros devem ser superiores a -100% a.a.');
    const bases={DU252:du === null ? null : du/252,DC365:dc/365,DC360:dc/360};
    exigir(Object.prototype.hasOwnProperty.call(bases,p.baseJuros),'Base de juros inválida.');
    const fj=Math.pow(1+juros/100,bases[p.baseJuros]);
    const corrigido=p.valorInicial*F, jurosReais=corrigido*(fj-1), vf=corrigido*fj;
    exigir(Number.isFinite(vf),'Valor final não é finito.');
    return {valorInicial:p.valorInicial,inicio:p.inicio,fim:p.fim,diasCorridos:dc,diasUteis:du,
      fatorIPCA:F,ipcaPct:(F-1)*100,fatorJuros:fj,jurosPct:(fj-1)*100,
      correcaoMonetaria:corrigido-p.valorInicial,valorCorrigido:corrigido,
      jurosRemuneratorios:jurosReais,valorFinal:vf,retornoTotalPct: p.valorInicial===0?0:(vf/p.valorInicial-1)*100,
      memoria:memoria,convenção:'(início, fim]',alertaFeriados:false,
      calendarioFeriados:diasUteisCobertos?'ANBIMA':'NÃO DISPONÍVEL'};
  }
  return {calcular,prepararSerie,fator,ciclo,data,iso,mesAtras};
})();
1 curtida

03_ImportarSGS.gs

/**
 * CALCULADORA DE INFLAÇÃO | 03_ImportarSGS.gs | v0.3
 * Carrega série SGS 433 (IPCA mensal) do Banco Central para 'Séries'.
 * NÃO deduz a data efetiva de publicação. Data na coluna D = importação.
 * API: "valor":"0.48" corresponde a 0,48% a.m. (e NÃO a 48%).
 * Na primeira atualização busca histórico de 1980 até hoje, em janelas < 10 anos.
 * Nas seguintes, revisita os últimos 6 meses para capturar correções.
 */
const IPCA_SGS_URL = 'https://api.bcb.gov.br/dados/serie/bcdata.sgs.433/dados';

function dataSGS_(iso) { return iso.slice(8,10)+'/'+iso.slice(5,7)+'/'+iso.slice(0,4); }

function sgsBuscar_(inicio, fim) {
  const url=IPCA_SGS_URL+'?formato=json&dataInicial='+encodeURIComponent(dataSGS_(inicio))+'&dataFinal='+encodeURIComponent(dataSGS_(fim));
  let resposta, texto;
  
  for (let tentativa=0; tentativa<4; tentativa++) {
    try {
      resposta=UrlFetchApp.fetch(url,{muteHttpExceptions:true,headers:{Accept:'application/json'}});
      texto=resposta.getContentText();
      
      if(resposta.getResponseCode()===200) {
        // Proteção: O BCB às vezes retorna 200 OK, mas envia um erro XML/HTML por instabilidade
        if (texto.trim().startsWith('<')) {
          throw Error('Servidor do Banco Central retornou XML em vez de JSON.');
        }
        break; // Sucesso real, sai do loop de tentativas
      }
      
      if(![429,500,502,503,504].includes(resposta.getResponseCode()))
        throw Error('SGS respondeu HTTP '+resposta.getResponseCode()+': '+texto.slice(0,120));
    } catch(e) { 
      if(tentativa===3) throw e; 
    }
    // Espera antes de tentar de novo, aumentando o tempo a cada falha
    Utilities.sleep(1000 * (tentativa+1));
  }
  
  let dados;
  try {
    dados=JSON.parse(texto);
  } catch (e) {
    throw Error('O Banco Central retornou dados em um formato inválido. Tente novamente.');
  }
  
  if(!Array.isArray(dados)) throw Error('Resposta inesperada do SGS.');
  
  return dados.map(item=>{
    if(typeof item.data!=='string'||!/^\d{2}\/\d{2}\/\d{4}$/.test(item.data))
      throw Error('SGS retornou competência inválida: '+item.data);
    const competencia=item.data.slice(6,10)+'-'+item.data.slice(3,5);
    const taxa=Number(String(item.valor).replace(',','.'));
    if(!Number.isFinite(taxa) || taxa<=-100) throw Error('Taxa inválida para '+competencia);
    return {competencia:competencia,taxaPct:taxa};
  });
}

function baixarSGSPeriodo_(primeiroAno, anoFinal, hojeISO) {
  const registros=[];
  for(let ano=primeiroAno;ano<=anoFinal;ano+=8) {
    const ultimo=Math.min(ano+7,anoFinal);
    const resposta=sgsBuscar_(ano+'-01-01',ultimo===anoFinal?hojeISO:ultimo+'-12-31');
    registros.push.apply(registros,resposta);
  }
  return registros;
}

/** Chamada interna do menu consolidado. Lança erros para reporte independente. */
function atualizarSerieIPCA_() {
  const ss=SpreadsheetApp.getActiveSpreadsheet();
  const planilha=ss.getSheetByName('Séries');
  if(!planilha) throw Error('Aba Séries ausente. Execute instalarCalculadora().');
  
  const atuais=lerSerieIPCA_();
  const mapa=new Map(atuais.map(x=>[x.competencia,x.taxaPct]));
  const hoje=new Date();
  const anoFinal=Number(Utilities.formatDate(hoje,ss.getSpreadsheetTimeZone(),'yyyy'));
  const hojeISO=Utilities.formatDate(hoje,ss.getSpreadsheetTimeZone(),'yyyy-MM-dd');
  let novos;
  
  if(atuais.length===0) {
    novos=baixarSGSPeriodo_(1980,anoFinal,hojeISO);
  } else {
    const maisRecente=atuais.map(x=>x.competencia).sort().pop();
    const inicio=new Date(Date.UTC(Number(maisRecente.slice(0,4)),Number(maisRecente.slice(5))-7,1));
    const primeiro=Utilities.formatDate(inicio,'UTC','yyyy-MM-dd');
    novos=sgsBuscar_(primeiro,hojeISO);
  }
  
  if(novos.length===0) throw Error('SGS retornou série vazia; nada foi gravado.');
  novos.forEach(x=>mapa.set(x.competencia,x.taxaPct));
  
  const lista=Array.from(mapa.keys()).sort().map(k=>({competencia:k,taxaPct:mapa.get(k)}));
  const agora=new Date(), matriz=lista.map(r=>[
    r.competencia,r.taxaPct,'BCB / SGS 433',agora,IPCA_SGS_URL,'Data de importação, não data de divulgação'
  ]);
  
  const apagar=Math.max(planilha.getLastRow()-5,matriz.length,1);
  if(planilha.getMaxRows()<apagar+5) planilha.insertRowsAfter(planilha.getMaxRows(),apagar+5-planilha.getMaxRows());
  
  const anteriores=planilha.getRange(6,1,apagar,6).getValues();
  const status=planilha.getRange('H3:H4').getValues();
  
  try {
    planilha.getRange(6,1,apagar,6).clearContent();
    planilha.getRange(6,1,matriz.length,1).setNumberFormat('@');
    planilha.getRange(6,1,matriz.length,6).setValues(matriz);
    planilha.getRange(6,2,matriz.length,1).setNumberFormat('0.00"%"');
    planilha.getRange(6,4,matriz.length,1).setNumberFormat('dd/MM/yyyy HH:mm');
    planilha.getRange('H3').setValue('IPCA atualizado em: '+Utilities.formatDate(agora,ss.getSpreadsheetTimeZone(),'dd/MM/yyyy HH:mm'));
    planilha.getRange('H4').setValue('Última competência: '+lista[lista.length-1].competencia);
  } catch(e) {
    try { planilha.getRange(6,1,apagar,6).setValues(anteriores); planilha.getRange('H3:H4').setValues(status); }
    catch(restaura) { console.error('Falha também ao restaurar IPCA: '+restaura.message); }
    throw Error('Falha ao gravar IPCA: '+e.message);
  }
  return {consultados:novos.length,total:lista.length};
}

/** Compatibilidade para quem ainda executa a rotina antiga manualmente. */
function atualizarSerieIPCA() {
  const ss=SpreadsheetApp.getActiveSpreadsheet();
  const lock=LockService.getDocumentLock();
  if(!lock || !lock.tryLock(20000)) { ss.toast('Outra atualização em andamento.','IPCA',5); return; }
  try {
    const r=atualizarSerieIPCA_();
    if(typeof recalcularSemAviso==='function') recalcularSemAviso();
    ss.toast(r.total+' competências de IPCA armazenadas.','IPCA atualizado',8);
  } catch(e) { SpreadsheetApp.getUi().alert('Falha ao atualizar IPCA:\n'+e.message); }
  finally { lock.releaseLock(); }
}

/** Só lê dados oficiais já importados; nunca usa data de importação como data de divulgação. */
function lerSerieIPCA_() {
  const sh=SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Séries');
  if(!sh||sh.getLastRow()<6) return [];
  return sh.getRange(6,1,sh.getLastRow()-5,2).getValues().filter(x=>x[0]!=='' && x[0]!==null).map(x=>{
    const competencia=String(x[0]).trim();
    if(!/^\d{4}-(0[1-9]|1[0-2])$/.test(competencia)) throw Error('Competência inválida na aba Séries: '+competencia);
    if(typeof x[1]!=='number'||!Number.isFinite(x[1])) throw Error('Taxa inválida para '+competencia+' em Séries.');
    return {competencia:competencia,taxaPct:x[1]};
  });
}

04_ImportarFERIADOS.gs

/**
 * CALCULADORA DE INFLAÇÃO | 04_ImportarFERIADOS.gs | v0.3
 * Fonte oficial: https://www.anbima.com.br/feriados/arqs/feriados_nacionais.xls
 * Importa XLS por conversão temporária ao Google Sheets (Drive API v3).
 * REQUISITO: Extensões > Apps Script > Serviços > adicionar Drive API (v3).
 * Não requer arquivo manual, ID de pasta ou aba intermediária.
 * Grava na aba Feriados; leitura pelo motor em formato ISO YYYY-MM-DD.
 * Intervalo coberto na fonte: anos de 2001 a 2099.
 */
const FERIADOS_ANBIMA_URL = 'https://www.anbima.com.br/feriados/arqs/feriados_nacionais.xls';

function feriadoNormalizarTexto_(valor) {
  return String(valor == null ? '' : valor).normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '').trim().toLowerCase();
}
function feriadoISOValido_(s) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
  const [a,m,d] = s.split('-').map(Number);
  const x = new Date(Date.UTC(a,m-1,d));
  return x.getUTCFullYear() === a && x.getUTCMonth() === m-1 && x.getUTCDate() === d;
}
function feriadoDataISO_(v, fuso) {
  if (v instanceof Date && !Number.isNaN(v.getTime())) return Utilities.formatDate(v, fuso, 'yyyy-MM-dd');
  if (typeof v === 'number' && Number.isFinite(v) && v >= 1 && v < 120000) {
    // Datas Excel convertidas para números seriais: origem 1899-12-30.
    const dt = new Date(Date.UTC(1899,11,30) + Math.floor(v) * 86400000);
    return Utilities.formatDate(dt, 'UTC', 'yyyy-MM-dd');
  }
  const s = String(v == null ? '' : v).trim();
  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
  const br = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(s);
  if (br) return br[3] + '-' + br[2].padStart(2,'0') + '-' + br[1].padStart(2,'0');
  return null;
}

/**
 * Recebe as linhas da planilha convertida. Detecta os cabeçalhos
 * Data, Dia da Semana e Feriado sem supor coluna fixa ou linha inicial.
 */
function interpretarXLSFeriadosANBIMA_(matriz, fuso) {
  if (!Array.isArray(matriz) || matriz.length < 5) throw Error('XLS da ANBIMA está vazio ou ilegível.');
  let cab = null;
  for (let r = 0; r < Math.min(matriz.length, 35); r++) {
    const colunas = matriz[r].map(feriadoNormalizarTexto_);
    const iData = colunas.findIndex(v => v === 'data');
    const iFeriado = colunas.findIndex(v => v === 'feriado');
    if (iData >= 0 && iFeriado >= 0) {
      cab = { linha: r, data: iData, feriado: iFeriado,
        semana: colunas.findIndex(v => v === 'dia da semana' || v === 'dia semana') };
      break;
    }
  }
  if (!cab) throw Error('Cabeçalhos Data e Feriado não encontrados no XLS da ANBIMA.');

  const registrados = new Set(), saida = [];
  for (let r = cab.linha + 1; r < matriz.length; r++) {
    const linha = matriz[r];
    const s = feriadoDataISO_(linha[cab.data], fuso);
    if (!s || !feriadoISOValido_(s)) continue; // Rodapés e observações não são datas.
    const ano = Number(s.slice(0,4));
    if (ano < 2001 || ano > 2200) throw Error('Data fora do intervalo esperado: ' + s);
    
    // CORREÇÃO: Ignora a linha se a ANBIMA tiver cadastrado o mesmo feriado duas vezes
    if (registrados.has(s)) continue; 
    
    const descricao = String(linha[cab.feriado] == null ? '' : linha[cab.feriado]).trim();
    if (!descricao) throw Error('Feriado sem descrição em ' + s);
    registrados.add(s);
    saida.push({ data: s,
      diaSemana: cab.semana >= 0 ? String(linha[cab.semana] || '').trim() : '',
      descricao: descricao });
  }
  saida.sort((a,b) => a.data.localeCompare(b.data));
  if (saida.length < 500) throw Error('Calendário insuficiente (' + saida.length + ' datas); atualização cancelada.');
  const anoIni = Number(saida[0].data.slice(0,4));
  const anoFim = Number(saida[saida.length-1].data.slice(0,4));
  if (anoIni > 2001 || anoFim < 2099) {
    throw Error('Calendário incompleto: ' + anoIni + ' a ' + anoFim + ' (esperado 2001 a 2099).');
  }
  // Pontos de controle estáveis: ajudam a rejeitar a importação de outra planilha.
  ['2025-12-25','2026-01-01','2026-10-12'].forEach(d => {
    if (!registrados.has(d)) throw Error('Calendário não contém feriado esperado: ' + d);
  });
  return { registros: saida, cobertura: { inicio: anoIni+'-01-01', fim: anoFim+'-12-31' } };
}

/** Busca binária, converte com Drive API v3 e elimina a cópia temporária. */
function baixarXLSFeriadosANBIMA_() {
  if (typeof Drive === 'undefined' || !Drive.Files || typeof Drive.Files.create !== 'function') {
    throw Error('Ative o serviço avançado Drive API (versão v3) em Apps Script > Serviços.');
  }
  const resposta = UrlFetchApp.fetch(FERIADOS_ANBIMA_URL, {
    muteHttpExceptions: true, followRedirects: true,
    headers: { 'Accept': 'application/vnd.ms-excel,application/octet-stream,*/*' }
  });
  if (resposta.getResponseCode() !== 200) {
    throw Error('ANBIMA respondeu HTTP ' + resposta.getResponseCode() + '.');
  }
  const blob = resposta.getBlob().setName('feriados_nacionais.xls')
    .setContentType('application/vnd.ms-excel');
  const tamanho = blob.getBytes().length;
  if (tamanho < 4096 || tamanho > 10000000) {
    throw Error('Arquivo XLS com tamanho inesperado (' + tamanho + ' bytes).');
  }
  let id = null;
  try {
    const arquivo = Drive.Files.create({
      name: 'TEMP_ANBIMA_FERIADOS_CONVERSAO',
      mimeType: 'application/vnd.google-apps.spreadsheet'
    }, blob, { fields: 'id,mimeType' });
    id = arquivo && arquivo.id;
    if (!id) throw Error('Drive não retornou identificador do XLS convertido.');
    let convertido;
    for (let n = 0; n < 4; n++) {
      try { convertido = SpreadsheetApp.openById(id); break; }
      catch (e) { if (n === 3) throw e; Utilities.sleep(700 * (n + 1)); }
    }
    for (const aba of convertido.getSheets()) {
      const matriz = aba.getDataRange().getValues();
      try { return interpretarXLSFeriadosANBIMA_(matriz, convertido.getSpreadsheetTimeZone()); }
      catch (e) {
        // Outras abas podem não conter a tabela principal.
        if (e.message.indexOf('Cabeçalhos Data e Feriado') < 0) throw e;
      }
    }
    throw Error('Nenhuma aba do XLS contém tabela Data / Feriado.');
  } finally {
    if (id) {
      try { DriveApp.getFileById(id).setTrashed(true); }
      catch (e) { console.warn('Não foi possível descartar o arquivo temporário ' + id + ': ' + e.message); }
    }
  }
}

/** Chamada interna pelo comando INFLAÇÃO > Atualizar dados. Lança erro em falhas. */
function atualizarFeriadosANBIMA_() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sh = ss.getSheetByName('Feriados');
  if (!sh) throw Error('Aba Feriados ausente. Execute instalarCalculadora().');
  // Não escreve nada antes de baixar e validar integralmente o XLS.
  const importado = baixarXLSFeriadosANBIMA_();
  const agora = new Date();
  const registros = importado.registros;
  const matriz = registros.map(x => [
    x.data, x.diaSemana, x.descricao, 'ANBIMA', agora,
    'Calendário de feriados bancários nacionais'
  ]);
  const quant = Math.max(1, sh.getLastRow() - 5, matriz.length);
  
  if (sh.getMaxRows() < quant + 5) sh.insertRowsAfter(sh.getMaxRows(), quant + 5 - sh.getMaxRows());
  
  // Cópia de segurança em memória, para tentar restaurar uma gravação incompleta.
  const anteriores = sh.getRange(6,1,quant,6).getValues();
  const status = sh.getRange('H3:H5').getValues();
  try {
    sh.getRange(6,1,quant,6).clearContent();
    sh.getRange(6,1,matriz.length,1).setNumberFormat('@');
    sh.getRange(6,1,matriz.length,6).setValues(matriz);
    sh.getRange(6,5,matriz.length,1).setNumberFormat('dd/MM/yyyy HH:mm');
    sh.getRange('H3').setValue('Atualizado em: ' + Utilities.formatDate(agora,ss.getSpreadsheetTimeZone(),'dd/MM/yyyy HH:mm'));
    sh.getRange('H4').setValue(registros.length + ' feriados cadastrados');
    sh.getRange('H5').setValue('Cobertura: ' + importado.cobertura.inicio + ' a ' + importado.cobertura.fim);
  } catch (e) {
    try {
      sh.getRange(6,1,quant,6).setValues(anteriores);
      sh.getRange('H3:H5').setValues(status);
    } catch (restaura) { console.error('Falha também ao restaurar feriados: ' + restaura.message); }
    throw Error('Falha ao gravar os feriados: ' + e.message);
  }
  return { total: registros.length, cobertura: importado.cobertura };
}

/** Permite atualização isolada pela lista de funções do Apps Script. */
function atualizarFeriadosANBIMA() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const trava = LockService.getDocumentLock();
  if (!trava || !trava.tryLock(20000)) { ss.toast('Já existe uma atualização em andamento.','Feriados',6); return; }
  try {
    const r = atualizarFeriadosANBIMA_();
    recalcularSemAviso();
    ss.toast(r.total + ' feriados atualizados.', 'ANBIMA', 8);
  } catch (e) { SpreadsheetApp.getUi().alert('Falha ao importar feriados ANBIMA:\n' + e.message); }
  finally { trava.releaseLock(); }
}

/**
 * Retorna {datas,cobertura} ao motor. Sem base, retorna conjunto vazio;
 * o motor proibirá DU/252 ou rateio por dias úteis nessas condições.
 */
function lerFeriadosANBIMA_() {
  const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Feriados');
  if (!sh || sh.getLastRow() < 6) return { datas: [], cobertura: null };
  const linhas = sh.getRange(6,1,sh.getLastRow()-5,1).getValues();
  const vistos = new Set(), datas = [];
  linhas.forEach((linha,i) => {
    if (linha[0] === '' || linha[0] === null) return;
    const d = String(linha[0]).trim();
    if (!feriadoISOValido_(d)) throw Error('Data inválida em Feriados!A' + (i+6) + ': ' + d);
    if (vistos.has(d)) throw Error('Data duplicada em Feriados: ' + d);
    vistos.add(d); datas.push(d);
  });
  if (datas.length === 0) return { datas: [], cobertura: null };
  if (datas.length < 500) throw Error('Aba Feriados contém dados incompletos. Use INFLAÇÃO → Atualizar dados.');
  datas.sort();
  const ai = Number(datas[0].slice(0,4)), af = Number(datas[datas.length-1].slice(0,4));
  if (ai > 2001 || af < 2099) throw Error('Aba Feriados sem cobertura de 2001 a 2099. Atualize os dados.');
  return { datas: datas, cobertura: {inicio:ai+'-01-01', fim:af+'-12-31'} };
}
2 curtidas