ApexでSalesforceとSVF Cloudを使ったお話

# はじめに
この記事は、Apexを使ったSVF Cloud（帳票ツール）連携の話になります。
SVF Cloudの文献がほぼないので残します。

# SalesforceとSVF Cloudの連携
まずは、下記のマニュアルに従って必要なパッケージをSalesforceへインストールします。
・SVF Cloud for Salesforce セットアップガイド
https://repo.svfcloud.com/manual/release/ja/setup_sfdc/ja/3994405.html
<br>
ApexでSVF Cloudを使用するためには、「プロシージャー」機能を使用することになります。
下記のマニュアルに従って、「テナントの設定」「アクセス権の付与」「プロシージャーの作成」などの設定を行ってください。

・SVF Cloud for Salesforce開発ガイド /プロシージャーを使って帳票を出力する /準備をする
https://repo.svfcloud.com/manual/release/ja/devGuide/sc4sfdev/ja/887605.html
<br>

※「プロシージャー」機能を使うためには、SVF Cloud上に帳票が作成されている前提となります。

# SVF Cloud連携でやったこと
①あるオブジェクト「プロシージャー」機能を使って帳票PDFを作成し、ファイル（Attachment）へ保存
②ファイル（Attachment）をBoxへアップロード

# ①あるオブジェクト「プロシージャー」機能を使って帳票PDFを作成し、ファイル（Attachment）へ保存

（参考ソース）
こちらは、下記のマニュアルを参考に作成しています。（ほぼ同じです。）
https://repo.svfcloud.com/manual/release/ja/devGuide/sc4sfdev/ja/887611.html

```apex:SvfCloudPDFAttachmentQueueableクラス
global with sharing class SvfCloudPDFAttachmentQueueable implements Queueable, Database.AllowsCallouts{
    private SvfCloudProcedure proc;
    private String recordId;
    private String fileName;
    
    global SvfCloudPDFAttachmentQueueable(SvfCloudProcedure proc, String recordId, String fileName){
        this.proc = proc;
        this.recordId = recordId;
        this.fileName = fileName;
    }
    
    public void execute(QueueableContext context) {
        
        List<String> recordIds = new List<String>{ recordId };
        Blob pdf = null;
        
        try {
            // プロシージャーを実行します。
            String actionId = proc.executeProcedure(recordIds, null);
            System.debug('actionID: '+actionId);
            
            // 処理状況を確認します。
            proc.waitStatus(60); 
            
            // PDFファイルを取得します。
            pdf = proc.downloadArtifact();
            
            // レコードに添付します。 
            Attachment attachment = new Attachment();
            attachment.Name = fileName;
            attachment.ParentId = recordId;
            attachment.Body = pdf;
            
            attachment.ContentType = proc.artifactContentType;
            Database.SaveResult saveResult = Database.insert(attachment, false);
            if (!saveResult.isSuccess()) {
                // ERROR
                System.debug('error attachement.');
            }      
        }
        catch (SvfCloudProcedure.SvfCloudException e){
            System.debug(e);
            throw e;
        }
        catch (CalloutException e){
            System.debug(e);
            throw e;
        }
    }
}
```

<details><summary>SvfCloudProcedureクラス（長いです）</summary>

```apex:SvfCloudProcedureクラス
global with sharing class SvfCloudProcedure {

    // SVF Cloud WebAPIのベースURL
    private final String svfCloudBaseURL;
    
    // SVF Cloudへの通信で使用する、「証明書と鍵の管理」に登録されたクライアント証明書の名前
    private final String httpsClientCertificateName;
    
    // 実行するプロシージャー名
    private final String procedureName;
    
    // SVF CloudからSalesforce Rest APIを実行するSalesforceユーザ名
    private final String execSalesforceUsername;
    
    // SVF Cloud プロシージャー処理受付REST
    private final String svfCloudProcedurePath = '/procedures/';
    // SVF Cloud 印刷状況の取得REST
    private final String svfCloudActionPath= '/actions/';
    
    // プロシージャー呼び出しのレスポンスとして得られる、成果物URL
    global String locationUrl  { get; set; }
    // プロシージャー呼び出しのレスポンスとして得られる、アクションID
    global String actionId  { get; set; }
    
    // 印刷状況取得のレスポンスとして得られる、処理状況
    global Action action { get; set; }
    
    // 成果物ダウンロード時のレスポンスとして得られる、content-type
    global String artifactContentType { get; set; }
    /**
        SVF Cloud プロシージャー実行でHTTPエラーが発生したことを示す共通例外クラス
    **/
    global virtual with sharing class SvfCloudException extends Exception {
        
        SvfCloudException(String stage, String actionId){
            this.stage = stage;
            this.actionId = actionId;
        }
        
        // 本サンプルコードにおける、エラー発生箇所
        global String stage { get; set; }
        
        // アクションID(処理ID)。プロシージャーが処理を受け付けた時に得られます。
        // SVF Cloud Managerの処理状況画面に「処理ID」として表示されます。
        global String actionId { get; set; } 
        
    }
    
    /**
        SVF Cloud プロシージャー実行でHTTPエラーが発生したことを示す例外クラス
    **/
    global with sharing class SvfCloudHttpException extends SvfCloudException {
        
        SvfCloudHttpException(String stage, String actionId, Integer httpStatus, String HttpBody){
            super(stage,actionId);
            this.httpStatus = httpStatus;
            this.httpBody = httpBody;
        }
        
        // エラー受信時のHTTPレスポンスステータス
        global Integer httpStatus { get;  set; }
        // エラー受信時のHTTPレスポンスボディ
        global String httpBody { get;  set; }
    }
    /**
        SVF Cloud プロシージャーでエラーが発生したことを示す例外クラス
    **/
    global with sharing class SvfCloudActionException extends SvfCloudException {
        
        SvfCloudActionException(String stage, String actionId, String actionState){
            super(stage,actionId);
            this.actionState = actionState;  
        }
        
        // エラー発生時の印刷ステータス
        global String actionState { get;  set; }
        
    }
    
    /**
        SVF Cloud WebAPIの印刷状況レスポンスJSONオブジェクトに含まれる成果物情報を表します。
    **/
    global with sharing class Artifact {
        global String path { get; set; } // 成果物ファイル名
        global String name { get; set; } // 成果物名
        global Integer pages { get; set; } // ページ数
    }
    
    /**
        SVF Cloud WebAPIの印刷状況レスポンスJSONオブジェクトを表します。
    **/
    global with sharing class Action {
        global String id { get; set; } // アクションID(処理ID)
        global String code  { get; set; } // エラーコード
        global String state { get; set; } // 印刷ステータス
        global Artifact artifact { get; set; } // 成果物情報
    }
    
    /**
        コンストラクタ
        
        @param svfCloudTenantId SVF CloudのテナントID
        @param certName クライアント証明書として使用する、Salesforceの「証明書と鍵」に登録した証明書の名前
        @param execSalesforceUsername SVF CloudからSalesforceへアクセスする際のSalesforceユーザ名
        @param procedureName SVF Cloudに作成したプロシージャーの名前
    **/
    global SvfCloudProcedure(String svfCloudTenantId, String certName, String execSalesforceUsername, String procedureName){
                
        this.svfCloudBaseURL = 'https://'+svfCloudTenantId+'.secure.svfcloud.com/api/v1';
        this.httpsClientCertificateName = certName;
        this.procedureName = procedureName;
        this.execSalesforceUsername = execSalesforceUsername;
    }
    
    /**
        SVF Cloudへプロシージャーの実行を依頼する。
        SVF Cloud側は、プロシージャー実行を受付しレスポンスをすぐに返す。処理自体は、その後にバックグラウンドで実行される。
        @param recordIdList SVF Cloudで処理するレコードIDのリスト。
            プロシージャー設定のパラメーター'id'に、シングルクォーテーションで囲まれたレコードIDがカンマ区切りで連結されて渡される。
        @param paramsMap SVF Cloudのプロシージャーのパラメーターに設定された項目を上書きするMap情報。
            Mapのkeyは、プロシージャー設定のパラメーター名を指定する。Mapのvalueは、シングルクォーテーションで囲まれる。
                
        @return アクションID
    **/
    global String executeProcedure( List<String> recordIdList, Map<String,String> paramsMap ) {
        
        String location;
        HttpRequest req = new HttpRequest();
        Http http = new Http();
        HttpResponse res = new HttpResponse();
        
        req.setMethod('POST');
        
        // 「証明書と鍵の管理」に設定した証明書の名前を指定します。
        req.setClientCertificateName(httpsClientCertificateName);
        
        // Salesforceユーザ名を指定します。Salesforceからデータを取得する際の実行ユーザとして使用されます。
        req.setHeader('X-SVFCloud-Subject', execSalesforceUsername);
        
        // タイムゾーンを指定します。SVF Cloudで帳票データ作成時の日時処理に使用されます。
        req.setHeader('X-SVFCloud-TimeZone', UserInfo.getTimeZone().toString());
        
        String endpoint = svfCloudBaseURL + svfCloudProcedurePath + procedureName;
        req.setEndpoint(endpoint);
        
        List<String> paramList = new List<String>{};
        if ( recordIdList != null && recordIdList.size()>=0 ) {
            // プロシージャー設定のパラメーター"id"と"limit"を作成します。
            String ids = '\'' + String.join(recordIdList,'\',\'') + '\'';
            paramList.add('id='+EncodingUtil.urlEncode(ids,'UTF-8'));
            paramList.add('limit='+String.valueOf(recordIdList.size()));
        }
        if ( paramsMap!=null && paramsMap.size()>0 ){
            for ( String key: paramsMap.keySet() ){
                paramList.add(key + '=' + EncodingUtil.urlEncode(paramsMap.get(key),'UTF-8'));
            }
        }
        String body = String.join(paramList,'&');
        System.debug('form body: '+body);
        
        req.setBody(body);
        req.setHeader('Content-type', 'application/x-www-form-urlencoded');
        req.setHeader('Content-Length',String.ValueOf(body.length()));
        req.setCompressed(false);
        try {
            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                //通常時
                res = http.send(req);
            }else{
                //テスト時
                res.setStatus('OK');
                res.setStatusCode(307);
            }
            
            System.debug('statusCode: ' + res.getStatusCode());
            // 307リダイレクトは必ず行われます。
            if (String.isEmpty(res.getStatus()) ||  res.getStatusCode()!=307 ){
                throw new SvfCloudHttpException('CALL_SECURE_PROCEDURE', null, res.getStatusCode(), res.getBody());
            }
            
            // リダイレクト先へ転送します。
            endpoint = res.getHeader('Location');
            System.debug('Redirect URL: '+endpoint);
            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                req.setEndpoint(endpoint);
            }

            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                res = http.send(req);
            }else{
                //テスト時
                res.setStatus('OK');
                res.setStatusCode(202);
            }

            System.debug('statusCode: ' + res.getStatusCode());
            
            // プロシージャー呼び出しの結果、正常な場合は、ダイレクトプリントでは202、その他は303が返ってきます。
            if (!String.isEmpty(res.getStatus()) &&  res.getStatusCode()!=303 && res.getStatusCode()!=202 ){
                System.debug(res.getBody());
                throw new SvfCloudHttpException('CALL_EXECUTE', null, res.getStatusCode(), res.getBody());
            }
            
            // Location ヘッダーには、プロシージャー呼び出しの結果生成されるPDFファイル等の成果物を取得するためのURLが設定されます。
            // また、URLには印刷状況の取得に必要なアクションIDが含まれます。
            location = res.getHeader('Location');
            
            System.debug('location: ' + location);
            
        }catch(System.CalloutException e) {
            System.debug('Callout error: '+ e);
            throw e;
        }
        
        //Apexテスト時のエラー回避
        if (Test.isRunningTest() == false) {
            this.locationUrl = location;
            this.actionId = parseActionId(location);
        }else{
            location = 'https://api.svfcloud.com/v1/artifacts/3dce0d82-74c3-4ed2-9174-6bbc83136546?action=9b42d519-49af-9999-85b5-7ec4a20628e4&ticket=99998e85392cb837020390a6dd86ca6aac12c1b539ea80f6f0b7a8d91681889d';
            this.locationUrl = location;
            this.actionId = parseActionId(location);
        }
        
        return this.actionId;
    }
    
    /**
        #executeProcedure()で実行したプロシージャー呼び出しの印刷ステータスを取得します。
        正常終了するかエラーになるまで、パラメーターに指定したrestTimeout秒待機します。
        コールアウトの制限事項やApexガバナ制限に抵触しないように、restTimeoutの値を指定してください。
        印刷ステータスでプロシージャー処理がエラーになったことが確認できた場合、例外を発生させています。
        
        なお、１回のプロシージャー呼び出しに対して本メソッドを複数回呼び出した場合、
        ２回目以降はrestTimeoutの値にかかわらず待機時間が0秒となることがありますので、
        本メソッドをループ呼び出しすることはしないでください。
        
        @param restTimeout 最大待機時間(秒)
        @return 印刷ステータスが 印刷データ作成終了以降かどうか
        
    **/
    global void waitStatus(Integer restTimeout) {
        
        action = retrieveActionStatus(this.actionId, restTimeout);
        System.debug('current state: '+action.state);
        if (
                action.state == '3'  // [印刷ステータス] 異常終了
             || action.state == '5'  // [印刷ステータス] キャンセル
             || action.state == '6'  // [印刷ステータス] 強制終了
        ) {
            // 正常に終了しなかった状態              
            throw new SvfCloudActionException('ACTION_ERROR', this.actionId, action.state );
        }
        return;
    }
    
    /**
        指定されたアクションの印刷状況をSVF Cloudから取得します。
        
        @param actionId アクションID(処理ID)
        @param restTimeout タイムアウト値(秒)
    **/
    global Action retrieveActionStatus(String actionId, Integer restTimeout){
        
        Action action;
        
        HttpRequest req = new HttpRequest();
        Http http = new Http();
        HttpResponse res = new HttpResponse();
        
        req.setMethod('GET');
        req.setClientCertificateName(httpsClientCertificateName);
        
        // Salesforceユーザ名を指定します。
        req.setHeader('X-SVFCloud-Subject', execSalesforceUsername);
        
        // タイムゾーンを指定します。
        req.setHeader('X-SVFCloud-TimeZone', UserInfo.getTimeZone().toString());
        
        String endpoint = svfCloudBaseURL+svfCloudActionPath+actionId+'?timeout='+String.valueOf(restTimeout); 
        System.debug('Action URL: '+endpoint);
        req.setEndpoint(endpoint);
        
        req.setHeader('Accept','application/json');
        req.setCompressed(false);
        
        try {

            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                res = http.send(req);
            }else{
                res.setStatus('OK');
                res.setStatusCode(307);
            }

            System.debug('statusCode: ' + res.getStatusCode());
            // 307リダイレクトは必ず行われます。
            if (String.isEmpty(res.getStatus()) ||  res.getStatusCode()!=307 ){
                throw new SvfCloudHttpException('CALL_SECURE_ACTION', null, res.getStatusCode(), res.getBody());
            }
            
            endpoint = res.getHeader('Location');
                
            System.debug('Action Redirect URL: '+endpoint);
            req =  new HttpRequest();
            req.setMethod('GET');
            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                req.setEndpoint(endpoint);
            }
            req.setHeader('Accept','application/json');
            req.setTimeout(120000); 
                
            Long startTime = DateTime.now().getTime();
            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                res = http.send(req);
            }else{
                res.setStatus('OK');
                res.setStatusCode(200);
            }
            Long finishTime = DateTime.now().getTime();
                
            System.debug('elapsed msec: '+(finishTime-startTime)+' statusCode: ' + res.getStatusCode());                
            
            if(String.isEmpty(res.getStatus()) ||  res.getStatusCode()!=200){
                throw new SvfCloudHttpException('ACTION', actionId, res.getStatusCode(), res.getBody());
            }
            
            String body = res.getBody();
            
            // レスポンスから処理状況を取得します。
            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                action = null;
            }else{
                //疑似的にアクションを作成
                Artifact af = new Artifact();
                af.path = 'testfile.pdf';
                af.name = 'pdf';
                af.pages = 1;
                
                action = new Action();
                action.id = 'xxx';
                action.code = '0';
                action.state = '0';
                action.artifact = af;
            }
            JSONParser parser = JSON.createParser(body);
            if ( parser.nextToken() != null ) {
                action = (Action)parser.readValueAs(Action.class);
            }
            if ( action == null || action.state == null ){
                // レスポンスに印刷ステータスが含まれていない場合
                System.debug(body);
                throw new SvfCloudActionException('INVALID_ACTION_RESULT', actionId, null);
            }
            System.debug('artifact: '+action.artifact.path);
            
        }catch(System.CalloutException e) {
            System.debug('Callout error: '+ e);
            throw e;
        }
        
        return action;
        
    }
    
    private String parseActionId(String locationUrl) {
        
        // LocationヘッダのURLからアクションIDを取得します。
        // ダイレクトプリント以外の場合、URLのquery parameterにAction IDが入っています。
        Pattern pt = Pattern.compile('action=(.+)&ticket');
        Matcher matcher = pt.matcher(locationUrl);
        String id = null;
        if ( matcher.find() ) {
            id = matcher.group(1);
            return id;
        }
        
        // ダイレクトプリントの場合は、URLのパスにAction IDが入っています。
        pt = Pattern.compile('actions/([0-9a-f-]*)$');
        matcher = pt.matcher(locationUrl);
        if ( matcher.find() ) {
            id = matcher.group(1);
            return id;
        }
        
        System.debug('invalid location URL: '+locationUrl);
        throw new SvfCloudActionException('GET_ACTION_ID',null,null);
    }
    
    /**
        #executeProcedure()で実行したアクションが生成したファイルをSVF Cloudからダウンロードする。
    **/
    global Blob downloadArtifact() {
        
        String downloadUrl = this.locationUrl;
        HttpRequest req = new HttpRequest();
        Http http = new Http();
        HttpResponse res = new HttpResponse();
        
        // 成果物のダウンロードを行います。
        req.setMethod('GET');
        req.setEndpoint(downloadUrl);
        req.setCompressed(false);
        
        try {
            System.debug('download URL: ' + downloadUrl);

            //Apexテスト時のエラー回避
            if (Test.isRunningTest() == false) {
                res = http.send(req);
            }else{
                Blob b = Blob.valueOf('test');
                
                res.setStatus('OK');
                res.setStatusCode(200);
                res.setBodyAsBlob(b);
            }
            
            System.debug('statusCode(1): ' + res.getStatusCode());
            if ( !String.isEmpty(res.getStatus()) &&  res.getStatusCode()==303 ){
                // 303 See Other が返ってきたら、リダイレクトします。
                String endpoint = res.getHeader('Location');
                System.debug('redirect URL: '+endpoint);
                req = new HttpRequest();
                req.setMethod('GET');
                req.setEndpoint(endpoint);
                res = http.send(req);
                System.debug('statusCode(2): ' + res.getStatusCode());
            }
            if (!String.isEmpty(res.getStatus()) &&  res.getStatusCode()==200 ){ 
                artifactContentType = res.getHeader('Content-Type');
                System.debug('download content type = '+artifactContentType);                
                return res.getBodyAsBlob();        
            }
            
            // ERROR
            throw new SvfCloudHttpException('DOWNLOAD_ERROR', this.actionId, res.getStatusCode(), res.getBody());
            
        }catch(System.CalloutException e) {
            System.debug('Callout error: '+ e);
            throw e;
        }
    }    
 }
```
</details>

<br/>

使い方の例：フローから見積書を作成するためのサンプル
```apex:SvfCloudForFlow_sampleクラス
public class SvfCloudForFlow_sample {

    static final String PROCEDURE_NAME = 'proc_sample'; // プロシージャ名
    
    @InvocableMethod(label='SVFCloudファイル作成' description='SVFCloudでPDFを作成し、ファイルに保存')
    static public void createAttachmentSVFCloudPDF(List<String> IDList) {
        // Apex ジョブキューにプロシージャー呼び出し処理を追加し、非同期に実行します。
        SvfCloudProcedure proc = new SvfCloudProcedure(
            System.label.SVFCloudTenantID,       // テナントID
            System.label.SVFCloudCertificateName,// 「証明書と鍵の管理」に登録した証明書の名前
            System.label.SVFCloudExecutingUser,  // SVF CloudからSalesforce RESTを実行するSalesforceユーザ名
            PROCEDURE_NAME             // SVF Cloud Managerで定義したプロシージャーの名前
        );        
        if(IDList != null){
            String fileName = null;

            //見積を取得
            Quote quote
                = [SELECT Id, 
                   Name
                   FROM Quote WHERE Id = :IDList[0]];
            
            //ファイル名を生成
            fileName = String.format('見積書_{0}.pdf', new List<Object>{quote.Name});
                        
            System.enqueueJob( new SvfCloudPDFAttachmentQueueable(
                proc,
                IDList[0],// レコードID
                fileName  // ファイル名
            ));
        }
    }        
}
```

# ②ファイル（Attachment）をBoxへアップロード
私が以前投稿した、下記の記事をご参考ください。
https://qiita.com/sio403/items/75d90b11d3c699b564d8#%E3%83%95%E3%82%A1%E3%82%A4%E3%83%ABattachment%E3%82%92box%E3%81%B8%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89box%E3%81%B8%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89%E3%81%97%E3%81%9F%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E5%85%B1%E6%9C%89%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%AE%E7%94%9F%E6%88%90

# おわりに
「プロシージャー」機能により、Apexから帳票印刷、PDFファイルの作成など自動化ができます。
また、今回は実装していませんが大量の印刷・PDFファイル作成にも対応しております。
機会がありましたら、ぜひご活用ください。