Salesforce：Visualforce＋Apexで帳票（PDF）を出力する機能を作る

Salesforceのデータを使って帳票を出力したい！
でも帳票１～２種類だから、有料のAppExchangeをサブスクするのもなぁ・・・
って時におすすめの開発方法です。

今回はVisualforce＋Apexで作っていきたいと思います。


### 今回実装する処理フロー
①レコードの詳細画面に設置した印刷用のカスタムボタンを押下
②事前確認画面が表示される。※
③帳票PDFが画面に表示される
④レコードの添付ファイルに③の帳票をPDFで保存

※今回、**②は特別**に実装してみました。もちろん省略可。
レコード上のデータで持つ必要はないけど、
帳票を印刷するときに反映したい！って時に使える機能です。割と便利。:relaxed:


### 構成
| 資源 |クラス名|用途 |
|:-----------:|:------------|:------------|
| Visualforce | PreConfirmPage      | 事前確認画面         |
| ApexClass   | PreConfirmController| 事前確認画面のコントローラ         |
| Visualforce | PDFPage             | 帳票PDFを表示する画面　      |
| ApexClass   | PDFController       | 帳票PDFのコントローラ           |


★今回は、取引先から印刷ボタンを押下し、取引先責任者の一覧を印刷する想定で作成してみました。
事前確認画面で帳票に印刷したい取引先責任者を選べるようにしています。

### 実装イメージ
<script async class="speakerdeck-embed" data-id="9454cc16255e40f9b160641dd5e892df" data-ratio="1.77777777777778" src="//speakerdeck.com/assets/embed.js"></script>

### 実装
```html:PreConfirmPage.page

<apex:page standardController="Account" extensions="PreConfirmController" lightningStylesheets="true" showHeader="false"
   applyBodyTag="false" applyHtmlTag="false" action="{!init}">
   <head>
    <style>
        body{margin-left:10px;margin-right:10px;font-size:15px; }
        .label{margin-top:30px;}
        .button{font-size:15px; margin-left:10px;}
    </style>    
   </head>

   <body>
      <apex:form Id="inputForm">
         <apex:pageMessages escape="false" />
         <apex:outputPanel>
            <p class="label">出力する取引先責任者にチェックをしてください。</p>
            <apex:pageBlock >
               <apex:pageBlockTable value="{!contactsSelects}" var="contact" columnsWidth="200px,200px,200px">
                  <apex:column >
                     <apex:facet name="header">出力対象</apex:facet>
                     <apex:inputCheckbox value="{!contact.selected}" selected="true" />
                  </apex:column>
                  <apex:column >
                     <apex:facet name="header">取引先責任者名</apex:facet>
                     <apex:outputText value="{!contact.contacts.Name}" />
                  </apex:column>
                  <apex:column >
                     <apex:facet name="header">電話</apex:facet>
                     <apex:outputText value="{!contact.contacts.Phone}" />
                  </apex:column>
                  <apex:column >
                     <apex:facet name="header">メールアドレス</apex:facet>
                     <apex:outputText value="{!contact.contacts.Email}" />
                  </apex:column>
               </apex:pageBlockTable>
            </apex:pageBlock>
            <apex:commandButton styleClass="button" action="{!goBack}" value="戻る" rendered="{!isDisplayBackButton}" />
            <apex:commandButton action="{!displayPDF}" value="次へ" rendered="{!isDisplayNextButton}" reRender="inputForm"/>
         </apex:outputPanel>
         
      </apex:form>
   </body>
</apex:page>

```

```PreConfirmController.cls
public with sharing class PreConfirmController{

    public Account currentRecord      {get; set;}
    public String  errorMessage       {get; set;}
    public boolean isDisplayBackButton{get; set;}
    public Boolean isDisplayNextButton{get; set;}
    public List<Contact> contacts     {get; set;}

    public List<contactWrapper> contactsSelects;

    public PreConfirmController(ApexPages.StandardController controller) {
        currentRecord = (Account)controller.getRecord();
        errorMessage  = '';
        isDisplayBackButton = false;
        isDisplayNextButton = false;
        contactsSelects = new List<contactWrapper>();
    }

    public PageReference init(){
        try{
            contacts = new List<Contact>([SELECT Id,Name,Phone,Email FROM Contact WHERE AccountId =: currentRecord.Id]);
            if(contacts.isEmpty()){
                displayError('表示する取引先責任者がいません。', true);
                return null;
            } 
            for(Contact c:contacts){
                contactWrapper cw = new contactWrapper(c);
                contactsSelects.add(cw);
            }
            isDisplayNextButton = true;
            return null;
        }
        catch(Exception ex){
            //エラー処理
            return null;
        }
    }
    
   /* 画面でチェックしたものを取得する用のラッパークラス */
    public class contactWrapper {
        public Contact contacts {get; private set;} 
        public Boolean selected {get; set;}
        public contactWrapper(Contact c) {
            this.contacts = c;
            this.selected = true;
        }
    }
     public List<contactWrapper> getContactsSelects(){
        return contactsSelects;
    }
    
   /* PDFを表示 */
    public PageReference displayPDF(){
        try{
            PageReference pageRef = Page.PDFPage;
            List<String> selectedContactIds = getSelectedContacts();
            if(selectedContactIds.isEmpty()){
                displayError('表示する取引先責任者が1人も選択されていません。', true);
                return null;
            }
            pageRef.getParameters().put('contactNum', String.ValueOf(selectedContactIds.size()));
            for(integer i = 0; i < selectedContactIds.size(); i++ ){
                pageRef.getParameters().put('contactIds'+ i, selectedContactIds.get(i));
            }
            pageref.getParameters().put('accountId',currentRecord.Id);
            
            /* 取引先の添付ファイルへPDFをInsert */
            insertAttachment(pageRef);
            return pageRef; 
        }
        catch(Exception ex){
             //エラー処理
             return null;
        } 
    }
    
    /* 事前確認画面にて選択された取引先責任者の情報を取得 */
    private List<String> getSelectedContacts(){
        List<String> contactIds = new List<String>();
        if(!contacts.isEmpty()){
            for(contactWrapper cw: contactsSelects){
                if(cw.selected) contactIds.add(cw.contacts.Id);
            }
        }
        return contactIds;
    }

    /* 取引先の添付ファイルへPDFをInsert */
    private void insertAttachment(PageReference page){
        Attachment AccountAttachment = new Attachment();
        AccountAttachment.Name = '取引先責任者一覧.pdf';
        /* Apexテストクラスではpage.getContentサポートされていないため注意 */
        AccountAttachment.Body = Test.isRunningTest()?Blob.valueof('test') : page.getContent();
        AccountAttachment.ParentId = currentRecord.Id;
        insert AccountAttachment;
    }
    
    /* 画面へエラーメッセージ表示 */
    private void displayError(String displayMessage,Boolean isDisplayBackButton){
        this.isDisplayBackButton = isDisplayBackButton;
        ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, displayMessage, 'detail - error'));
        return;
    }
    
    /* 戻るボタン押下時処理 */
    public PageReference goBack(){
        String prevURL = '/' + currentRecord.Id;
        PageReference pageRef = new PageReference(prevURL);
        return pageRef; 
    }
}
```

```html:PDFpage.page
<apex:page standardController="Account" extensions="PDFController" showHeader="false" applyBodyTag="false" applyHtmlTag="false"
    renderAs="PDF" action="{!displayPDF}">

    <head>
        <style>
            body {font-family: 'Arial Unicode MS';font-size: 14px;line-height: 1.3}
            @page {size: A4;margin: 8mm 15mm 8mm 20mm;}
            /*　その他CSS は長いため省略・・・*/
        </style>
    </head>
    <body>
        <p class="right-aligned  print-date">出力日：{!YEAR(TODAY())}年{!MONTH(TODAY())}月{!DAY(TODAY())}日</p>
        <p class="right-aligned">Sample帳票</p>  
        <div class="header">
            <p id="title">会社名：{!AccountRecord.Name} の取引先責任者一覧</p>
        </div>
        <br />
        <table class="table">
                <tr>
                    <th>取引先責任者名</th>
                    <th>電話</th>
                    <th>メールアドレス</th>
                </tr>
           <apex:repeat value="{!displayContacts}" var="c">
                <tr>
                    <td>{!c.Name}</td>
                    <td>{!c.Phone}</td>
                    <td>{!c.Email}</td>
                </tr>
            </apex:repeat>
        </table>
        <p class="concluding-remarks">以上</p>
    </body>
</apex:page>
```

```PDFController.cls
public with sharing class PDFController {
    public Account AccountRecord         {get; set;}
    public List<Contact> displayContacts {get; set;}

    public PDFController(ApexPages.StandardController controller) {   
        String AccountId = ApexPages.currentPage().getParameters().get('accountId');
        List<Account> Accounts = new List<Account>([
            SELECT Id,Name
            FROM   Account
            WHERE  Id =: AccountId
        ]);
            AccountRecord = Accounts.get(0);
    }

    public PageReference displayPDF(){
        try{
            /* 事前確認画面で選択した取引先責任者の数とIdをパラメータから取得 */
            List<Id> targetContactIds = new List<Id>();
            Integer contactNumParam = Integer.ValueOf(ApexPages.currentPage().getParameters().get('contactNum'));
            for(integer i = 0; i < contactNumParam; i++){
                targetContactIds.add(ApexPages.currentPage().getParameters().get('contactIds' + i));
            }
            
            displayContacts = new List<Contact>([
                SELECT Id,Name,Phone,Email
                FROM   Contact
                WHERE  ID IN: targetContactIds
            ]);
            return null;
            
        }catch(Exception ex){
           //エラー処理
           return null;
        }           
    }
}  
```
:::note warn
Visualforce PDFの注意点
①VisualforceをPDFにしたい場合は、<apex:page> の属性 「renderAs="PDF"」 を指定する。
②フォントは現執筆時点で１種類しかない。「Arial Unicode MS」を指定しないと、日本語は表示できない。
　※異体字や旧字は一部サポートされていないものがあるので注意
③PDFのサイズやマージンを指定する場合は@pageを使う。
④JavaScriptはサポートされていない
⑤ブラウザによってはエラーが出てしまうことがある
　※Firefoxのセキュリティ設定が「標準」だとエラーになった。⇒「カスタム」ならOK
⑥CSSの「break-word」が効かないので、長文の文字列はコントローラで改行処理を行う必要がある。

:::

### 最後に
なんだかフォントが気に入らないですが、ずっと見てたら慣れました。(笑)

フルで実装したらとても長くなったので、エラー処理など一部コード省いています。
あと今回はVFに書いてますがCSSは静的リソースから読み込むようにした方がいい。

備忘録としたくて急いで書いたのでちょっとコード汚い・・・:kissing_smiling_eyes:　(笑)