O vendedor fecha a Oportunidade no Salesforce, abre um documento modelo no Word, e começa a copiar valores célula por célula: quantidade, preço, desconto, total. É onde nasce a proposta que sai com o número errado, com o desconto que já mudou na negociação e com o CNPJ do cliente anterior que ninguém apagou. O dado está todo lá dentro do CRM. O PDF é que insiste em ser feito à mão.
Para gerar a proposta em PDF a partir da Oportunidade, você monta uma página Visualforce com
renderAs="pdf" que lê a Oportunidade e as linhas de produto, e um método Apex que chama
getContentAsPDF() para salvar o arquivo como um File no próprio registro. O
layout vira template, os números vêm do banco, e o desconto é calculado na página.
Por que copiar valor à mão é o erro que ninguém audita
O problema da proposta feita no Word não é o tempo perdido, embora sejam quinze minutos por proposta que ninguém contabiliza. O problema é que ela cria uma segunda fonte de verdade para um número que já existe. Quando o desconto está no Salesforce e num documento solto, os dois vão divergir, e o que o cliente assina passa a ser diferente do que o forecast projeta. Você descobre isso no fechamento do mês, quando a receita reconhecida não bate com o pipeline.
Gerar o PDF direto do registro fecha essa brecha: existe um único lugar onde o valor mora, a Oportunidade, e o documento é uma projeção dele. Mudou o desconto na linha do produto, o próximo PDF já sai correto. Ninguém precisa lembrar de atualizar duas coisas, porque só existe uma.
A anatomia do dado: a Oportunidade não guarda os itens
O primeiro engano de quem começa é achar que os produtos da proposta estão na Oportunidade. Não estão. A
Oportunidade guarda o cabeçalho (cliente, estágio, valor total), e cada item é um registro de
OpportunityLineItem, filho, que aponta para uma PricebookEntry (a combinação de
produto com tabela de preço). É de lá que você tira quantidade, preço unitário e desconto por linha.
Então a consulta que alimenta a proposta é uma query no filho, trazendo os campos que o cliente vai ver:
List<OpportunityLineItem> itens = [
SELECT Product2.Name, Quantity, UnitPrice, Discount, TotalPrice
FROM OpportunityLineItem
WHERE OpportunityId = :oppId
WITH USER_MODE
ORDER BY Product2.Name
];
O WITH USER_MODE ali não é enfeite: o controller que serve o PDF deve respeitar o que o
usuário logado pode ver, exatamente como qualquer outra classe que devolve dados para a tela. Se você
quer entender por que Apex ignora essas permissões por padrão, eu destrinchei isso no post sobre
segurança em Apex com CRUD, FLS e sharing. Para uma
proposta comercial vale a mesma regra: a classe é with sharing e a query roda em modo de
usuário.
A página Visualforce que vira PDF
Visualforce continua sendo a forma mais direta de produzir PDF nativo no Salesforce, sem depender de
biblioteca externa nem de app pago. O truque é uma linha só: o atributo renderAs="pdf" na
tag <apex:page>. A mesma página que renderiza HTML no navegador passa a devolver um
PDF quando esse atributo está ligado.
<apex:page controller="PropostaPdfController" renderAs="pdf"
applyHtmlTag="false" showHeader="false">
<h1>Proposta comercial</h1>
<p>Cliente: {!oportunidade.Account.Name}</p>
<apex:dataTable value="{!itens}" var="i">
<apex:column headerValue="Produto">{!i.Product2.Name}</apex:column>
<apex:column headerValue="Qtd">{!i.Quantity}</apex:column>
<apex:column headerValue="Total">{!i.TotalPrice}</apex:column>
</apex:dataTable>
</apex:page>
Aqui mora a armadilha que mais me consome tempo em projeto real. Visualforce só lê getters.
Se no controller você declara public Decimal totalGeral; e referencia {!totalGeral}
na página, o campo sai em branco no PDF, sem erro nenhum no log. A página simplesmente não enxerga um
atributo público que não tenha método get. A correção é transformar tudo que a página lê em
propriedade com getter:
public Decimal totalGeral { get; private set; }
public List<OpportunityLineItem> itens { get; private set; }
public Opportunity oportunidade { get; private set; }
Guarde essa regra: campo em branco no PDF quase nunca é dado faltando no banco, é um getter que não existe. Numa org de demonstração que mantenho (a Vetra, distribuidora fictícia do meu portfólio), a primeira versão da proposta saiu com o rodapé de total zerado por causa exatamente disso, com os dados certos gravados no registro o tempo todo.
A tabela de itens e a conta do desconto feita na frente
A parte que dá trabalho de verdade não é o layout, é a conta do desconto. Cada linha tem um desconto
próprio (o campo Discount do OpportunityLineItem, em percentual), e o desconto
do cabeçalho não é digitado: ele é o desconto médio ponderado das linhas. Deixar o usuário digitar um
número solto no cabeçalho é criar a divergência que a proposta deveria matar. Veja a conta com os números
de uma Oportunidade real da Vetra:
| Produto | Qtd | Preço unit. | Bruto | Desc. | Líquido |
|---|---|---|---|---|---|
| Filtro industrial A | 100 | R$ 50,00 | R$ 5.000 | 10% | R$ 4.500 |
| Válvula B | 40 | R$ 120,00 | R$ 4.800 | 5% | R$ 4.560 |
| Conexão C | 200 | R$ 12,50 | R$ 2.500 | 0% | R$ 2.500 |
| Total | R$ 12.300 | 6,02% | R$ 11.560 |
Repare que o desconto do cabeçalho, 6,02%, não é uma média simples dos percentuais das linhas (que daria 5%). É o desconto total em dinheiro dividido pelo bruto: R$ 740 de desconto sobre R$ 12.300 de bruto dá 6,02%. Essa é a conta que o cabeçalho tem que refletir, e é por isso que o desconto agregado precisa ser derivado das linhas, nunca um campo que alguém preenche na unha. No Apex, o cálculo que alimenta o PDF é literalmente isso:
Decimal bruto = 0, liquido = 0;
for (OpportunityLineItem i : itens) {
Decimal brutoLinha = i.Quantity * i.UnitPrice;
bruto += brutoLinha;
liquido += i.TotalPrice; // TotalPrice já é líquido do desconto da linha
}
Decimal descontoGeral = bruto == 0 ? 0 : (bruto - liquido) / bruto * 100;
Esse é o mesmo princípio de fonte única que eu defendo em qualquer campo agregado: o total mora nas linhas e o cabeçalho é um reflexo. Forçar o número nos dois lugares é o caminho garantido para a proposta dizer uma coisa e o relatório de vendas dizer outra. E se o desconto de cada linha nem deveria ser digitado à mão, mas sim vir de uma tabela de faixas, o passo anterior a esta proposta é calcular o desconto por volume direto na Oportunidade, com as faixas em Custom Metadata aplicando o percentual sozinhas.
O botão que gera e anexa o PDF ao registro
Renderizar a página no navegador já resolve para quem quer imprimir. Mas a proposta profissional fica
anexada à Oportunidade, versionada, pronta para enviar. Para isso, um método Apex chama a própria página
Visualforce, captura o binário com getContentAsPDF() e grava como um ContentVersion
(o File moderno do Salesforce):
public static void gerarProposta(Id oppId) {
PageReference pg = Page.PropostaPdf;
pg.getParameters().put('id', oppId);
Blob corpo = pg.getContentAsPDF();
ContentVersion cv = new ContentVersion();
cv.Title = 'Proposta - ' + oppId;
cv.PathOnClient = 'proposta.pdf';
cv.VersionData = corpo;
cv.FirstPublishLocationId = oppId; // já vincula o arquivo à Oportunidade
insert cv;
}
Definir FirstPublishLocationId com o Id da Oportunidade faz o Salesforce criar o vínculo do
arquivo com o registro sozinho, sem você mexer em ContentDocumentLink na mão. O PDF aparece
na related list de Files da Oportunidade no instante seguinte.
O teste que o getContentAsPDF() não deixa você escrever
Aqui está o segundo tropeço que só aparece em produção, ou melhor, na hora de subir o código. A chamada
getContentAsPDF() lança exceção quando roda dentro de um contexto de teste. Você não
consegue, num @isTest, gerar o PDF de verdade e conferir o binário. Se a sua única cobertura
depende de chamar o método inteiro, o deploy quebra.
A saída é separar o que dá para testar do que não dá. A montagem dos itens, o cálculo do desconto e a
gravação do ContentVersion são lógica pura de dados: cobre com asserts normais, criando a
Oportunidade e as linhas no @testSetup. Só a linha do getContentAsPDF() fica de
fora, isolada num método próprio, e você a valida por fora, rodando em Anonymous Apex ou abrindo a página
com ?id=006...&renderAs=pdf no navegador. É uma verificação de fumaça manual, e tudo
bem: o que importa cobrir de automático é a conta do desconto, não o motor de renderização da Salesforce.
Um detalhe de teste que economiza uma tarde: para criar PricebookEntry num teste, o Id da
tabela de preço padrão vem de Test.getStandardPricebookId(), não de uma query. Fora de teste,
a tabela padrão você pega por SOQL. Trocar um pelo outro é um dos erros mais chatos de diagnosticar, porque
a mensagem de erro não diz que o problema é o Pricebook.
Quando não vale a pena construir isso
Ser honesto aqui importa mais que vender horas. Se a sua empresa emite dez propostas por mês, todas com o mesmo layout simples, Visualforce e Apex são a escolha certa: baixo custo, controle total, zero licença nova. Mas se o time de vendas precisa editar o template sozinho toda semana, se você tem vinte modelos diferentes por segmento, ou se a proposta puxa conteúdo de fora do Salesforce, um app de geração de documentos da AppExchange (Conga, PDF Butler, Titan) passa a compensar o custo de licença, porque ele entrega editor visual e versionamento de template que você não vai querer reconstruir em Visualforce.
E tem o passo seguinte: depois de gerar o PDF, a proposta em geral vai para assinatura. Se esse é o seu caso, o esforço de integração de assinatura eletrônica tem armadilhas próprias que já documentei no post sobre integrar Salesforce com ClickSign, principalmente o webhook que devolve o status do documento assinado. Gerar o PDF é metade do caminho; fechar o ciclo com a assinatura é a outra. E se ainda está decidindo se essa automação deveria ser Apex ou Flow, o post sobre quando usar Flow ou Apex ajuda a traçar a linha: geração de PDF com layout e cálculo é território de Apex, não de Flow.
Quer a proposta em PDF saindo direto da Oportunidade, com o desconto certo?
Eu construo a página, o controller e o botão que gera a proposta anexada ao registro, com o cálculo do desconto derivado das linhas e o teste que sobe sem quebrar. Comece com um diagnóstico gratuito de 45 minutos.
Falar no WhatsApp Ver serviçosPerguntas frequentes
Dá para gerar PDF no Salesforce sem app pago da AppExchange?
Sim. Uma página Visualforce com renderAs="pdf" devolve um PDF nativo, sem custo de
licença. Apps como Conga ou PDF Butler valem quando você precisa de editor visual para o time de vendas
e dezenas de modelos. Para poucos layouts, Visualforce e Apex resolvem.
Por que meu campo não aparece no PDF do Visualforce?
Visualforce só enxerga getters. Um atributo público sem método get renderiza vazio, sem
erro. Transforme em propriedade com getter (public Decimal total { get; set; }) ou escreva
um getTotal(). É a causa número um de PDF com campo em branco.
Como testar getContentAsPDF() se ele não roda em teste?
Ele lança exceção em contexto de teste. Cubra a lógica de dados (montagem dos itens, cálculo do
desconto, gravação do ContentVersion) com asserts e valide a renderização por fora, em
Anonymous Apex ou abrindo a página com ?renderAs=pdf no navegador.
O desconto da proposta deve ficar no cabeçalho ou nas linhas?
Nas linhas. Cada OpportunityLineItem tem seu desconto, e o desconto do cabeçalho é
derivado (soma dos descontos das linhas sobre o bruto). Digitar um número solto no cabeçalho faz a
proposta e o forecast divergirem.
