Notesからジョブカンに機能を移行＋Salesforceにも連携させる

**「レガシー」を保守したり、刷新したりするにあたり得られた知見・ノウハウ・苦労話 by Works Human Intelligence Advent Calendar 2024 21日目**

Notesで行っていた各種の申請処理をジョブカンに移行しました。その中に営業日報もあったので更にSalesforceとも連携できるようにしたいと思います。


+ **Notesの外回り日報（いわゆる営業日報）**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/3a31b23a-8b6c-458e-f2d8-9719347fc250.png)

+ **ジョブカンの画面イメージ**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/b0c0f1c9-fa68-b848-5bd8-9539c8455393.png)

まぁ、ジョブカンへの移行自体はそんなに難しくない。この日報情報ってSalesforceで言えば活動の記録なんですよね。今まではプラットフォームがNotesだったのでデータ連携の自動化を諦めてたのですが、今回はジョブカンです。おまけにβ版ですがAPIもあるので連携できそうです。

新規得意先の開設の申請などもジョブカンに移行してyoomを使ってSalesforceと連携しています。
今回もyoomを使う方法もあったのですが、毎日ユーザの人数分だけ処理を流さないといけないのでyoomだと月間無料タスク数に収まるかが不安ということで、Salesforceから直接ジョブカンにアクセスして情報を連携することにします。

+ [Yoomでジョブカンと連携する](https://qiita.com/Keiji_otsubo/items/1fe7a01eded01c0cf681)


# まずはPostmanを使ってジョブカンのAPIを使ってみました。

+ [ジョブカン経費精算/ワークフローAPI（β版）を使ってみる](https://qiita.com/Keiji_otsubo/items/7805d3fdc976178e2114)

使えそうです。ということで早速Salesforceからコールアウトしてみました。今回はとりあえずJobcan053というカスタムオブジェクトに登録します。

ジョブカンの営業日報を日にちを指定して読み取る処理です。
req.setHeader('Authorization',APIToken);にはアクセストークンを指定してください。

```Java
@AuraEnabled
    public static String calljobcan_org2() {
        Http http = new Http();
        String path ='https://ssl.wf.jobcan.jp/wf_api/v2/';
        String form_id = '84166435';
        String completed_after =  '2024/12/15%2000:00:00';//スペースは%20でエンコードする
        String completed_before = '2024/12/16%2000:00:00';//スペースは%20でエンコードする
        String parameters = 'requests/' + '?form_id=' + form_id  + '&completed_after=' + completed_after + '&completed_before=' + completed_before;
        HttpRequest req = new HttpRequest();
        req.setEndpoint(path + parameters);
        req.setHeader('Authorization',APIToken);
        req.setMethod('GET');
        
        HttpResponse res = http.send(req);
        system.debug(Logginglevel.INFO,'===> '+ res.getBody());
		Body053 b53 = (Body053)JSON.deserializeStrict(res.getBody(),Body053.class);
        
        List<results053> l53 = b53.results;
        system.debug(Logginglevel.INFO,'========= callfree_org2=======> '+ l53.size());
        if (b53.count != l53.size()) {
            system.debug(Logginglevel.INFO,'========= callfree_org2 取得した件数不一致 =======> ');
        }
        
        String strRes = '';
        List<Jobcan053__c> insertList53 = new List<Jobcan053__c>();
        for ( results053 r : l53){
        	Jobcan053__c j53 = new Jobcan053__c();
            j53.Name = r.id;
            j53.JobcanId__c = r.id;
            j53.title__c = r.title;
            insertList53.add(j53);
        }
        
        if (insertList53.size() > 0) {
            upsert insertList53 JobcanId__c;
        }
        
        return strRes;
        //return '';
    }
```

JSON.deserializeStrictを使って一発でクラスに変換するために定義したクラスは以下の通りです。


```Java
    /**
	 * 053の申請のレスポンス
     */  
    public class Body053 {
        public Integer count;
        public String next;
        public String previous;
        public List<results053> results;
    }
    
    /**
	 * 053の各レコード情報
     */  
    public class results053 {
        public String id;
        public String title;// "sample_title",
        public String status;// "in_progress",
        public Integer form_id; // 100,
        public String form_name; // "サンプルフォーム",
        public String form_type; //"expense",
        public String settlement_type;// "pre_application",
        public String applied_date;// "2021-03-08T13:42:23+09:00",
        public String applicant_code;// "user01",
        public String applicant_last_name;// "ジョブカン",
        public String applicant_first_name;// "太郎",
        public String applicant_group_name;// "営業",
        public String applicant_group_code;// "300",
        public String applicant_position_name;// "部長",
        public String proxy_applicant_last_name;// "代理ジョブカン",
        public String proxy_applicant_first_name;// "代理太郎",
        public String group_name;// "サンプルグループ",
        public String group_code;// "200",
        public String project_name;// "サンプルプロジェクト",
        public String project_code;// "100",
        public String flow_step_name;// "ステップ１",
        public String is_content_changed;// true,
        public Integer total_amount;// 1234,
        public String pay_at;// "2021-01-01T11:41:21+09:00",
        public String final_approval_period;// "2021-02-02T12:22:23+09:00",
        public String final_approved_date;// "2021-03-03T03:32:33+09:00"
    }
```

# 処理の考え方

+ 上記コールアウト処理を使って指定した日にちの日報データをSalesforceに取り込みます。**/v2/requests/** ではジョブカンのIdとどのような申請フォームでも共通の部分しか呼び出せないので、まずはSalesforceにジョブカンのIdを連携させます。最終的には初期値の移行作業もあるので、画面から日にちを指定して流せること、毎日決まった時間に流れるような機能も実装したいと思います。
+ 次に取得したジョブカンのIdを使って明細情報を取り込みます。 今回はエンドポイントに **/v1/requests/** を使います。この部分はバッチ処理と画面から１件だけ処理できるように工夫します。

  + [外部のシステムにコールアウトしてオブジェクトを更新するApexクラスを複数件対応にする](https://qiita.com/Keiji_otsubo/private/b6575aaa41576e52cbfc)

コールアウト処理をApexバッチから行うのに少々苦労しました。（上記記事を参照...）


また今回はあえて、得意先の情報を正規化せずにこのオブジェクトに保存させておきます（カスタムオブジェクトを増やしたくないので...）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/463c6495-d8c9-5bbd-69cb-8d6eeb27c950.png)

+ ジョブカンの詳細情報を取り込んだらTaskレコードを作成します。これもバッチ処理と画面から１件だけ処理できるように工夫させておきます。デバックするのに都合がいいように。

レコードの開いた時に手動で流せるようにボタンからも起動。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/88285cc0-a64a-bd37-3a48-48d89fbdb103.png)

取引先オブジェクトの活動です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/97a0ab02-991a-27a5-20bf-f2711fd88955.png)

これで、活動の記録は自動で連携できそうな感じです。


# レガシーではできなかったAPI連携

以下でも苦労している感じです。私もおそらくNotesではREST APIを実現できなかったでしょう。

https://stackoverflow.com/questions/42231724/lotus-notes-enable-api-data


またApexバッチの中からコールアウト処理(外部のAPIを呼んでレスポンスからレコードを作成したり更新する)を複数するコードは初めて書きました。以前からどうやるんだろうっては思っていましたが、エラーに悩まされましたが解決できました。悩むより手を動かしてコードを書いた方が早かったです。今までNotesで可視化してもデータとして活用しきれてなかった「活動の記録」を蓄積できるようになったので、これからはこのデータを使って営業の支援ができればと思います。

### 調子に乗ってジョブカンで処理された申請の件数をフローを使ってメール送信しようとしましたが...

:::note alert
System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out
:::

Apexを使ったコールアウト処理が使えないですね。今回は戻り値が欲しいので@future(callout=true)も使えません。

+ [HTTP コールアウト](https://qiita.com/Keiji_otsubo/items/638db545ae4949fbbd8d)

失敗したフローを消そうとしても...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/ccfdfbc8-1fab-7102-b7b8-c27b06239c0b.png)

外部サービスとしてフローから実行させると上記問題は回避できましたね。

+ [フローを使ってジョブカン経費精算/ワークフローAPI（β版）を呼び出す](https://qiita.com/Keiji_otsubo/items/7925dfad968c5a65b2fc)

# Sandboxから本番環境に移す前に、さらに考える

Salesforceの場合、SandboxにあるApexのコードは自由に編集できるのですが、本番環境はSandboxで変更したものをデプロイしないといけません(直接編集できません)
まだまだ例外事項も発生することを考えると、ジョブカン側との連携処理はSandboxで行って活動の記録の登録処理だけを本番環境のApex REST APIにさせるといいかもしれません。

Sandboxからレコードの元になるJosnのBodyを投稿して、本番環境で登録させるような流れです。

指定ログインを使ってApexから別のSalesforceの組織にコールアウトはできました。以下の手順で可能です。ただし以下の手順は自分自身の環境に接続する時の方法なので、コールアウトURLのところは注意です。

接続アプリケーション:コールアウトしたい相手の環境で作成
認証プロバイダー：Apexのコードがある環境で作成
指定ログイン：Apexのコードがある環境で作成

+ [Apex Wrapper SalesforceメタデータAPI（apex-mdapi）で指定ログイン情報の使用](https://qiita.com/Keiji_otsubo/items/43f633ae40495cf1e984)

```Java
    /**
	 * dev3からquoteのSandboxにコールアウトする。指定ログインを使う
     */  
    public static String callout_org() {

        Http http = new Http();
        String path = 'callout:ApexMDAPI';//指定ログインを使う
        String parameters = '/services/data/v62.0/limits';
        HttpRequest req = new HttpRequest();
        req.setEndpoint(path + parameters);
        req.setMethod('GET');
        
        HttpResponse res = http.send(req);
        system.debug(Logginglevel.INFO,'===> '+ res.getBody());
        return res.getBody();
    }
```

### Apex REST API を作ってみる。

Psotする内容を考慮してカスタムクラスを定義してみましたが、エラーになります。最終的にはうまく機能するようになりましたが... 2時間程悩みました。

+ [Apex REST でのエラー(入力に使うJsonの設定とクラスの定義)](https://qiita.com/Keiji_otsubo/items/2b2530431961669cf259)

Postmanを使ってテストしています。複数件のJsonを投稿しても機能していますね。使えそうな感じです。
後はこのApex REST のテストクラスを書いて、コールアウトするバッチ処理を作ればできそうです。

複数件のタスクのIdも返すことができています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/27584f35-85ac-6538-0ce3-c7807d2404e3.png)

```Java
@RestResource(urlMapping='/Task/*')
global with sharing class CreateTaskRESTAPI {
    
    
    @HttpPost
    global static string doPost(List<Body053> b53List){
        //system.debug(Logginglevel.INFO,'==========> '+ b53List);
         
        Set<String> customer_codeSet = new Set<String>();
        Set<String> supplier_codeSet = new Set<String>();
        Set<String> OwnerNameSet = new Set<String>();
        
        for (Body053 b53 : b53List) {
            if (b53.customer_code != null) customer_codeSet.add(b53.customer_code);
            if (b53.supplier_code != null) supplier_codeSet.add(b53.supplier_code);
            if (b53.OwnerName != null) OwnerNameSet.add(b53.OwnerName);
        }
        List<Account> accountList1 = [select Id,customer_code__c from Account where customer_code__c =: customer_codeSet];
        List<Account> accountList2 = [select Id,supplier_code__c from Account where supplier_code__c =: supplier_codeSet];
        List<User> userList = [select Id,username from user where username =: OwnerNameSet];
        
        Map<String,String> accountMap1 = new Map<String,String>();
        for (Account a : accountList1){
            accountMap1.put(a.customer_code__c,a.Id);
        }
        
        Map<String,String> accountMap2 = new Map<String,String>();
        for (Account a : accountList2){
            accountMap2.put(a.supplier_code__c,a.Id);
        }
        
        Map<String,String> userMap = new Map<String,String>();
        for (User u : userList){
            userMap.put(u.username,u.Id);
        }
        
        List<Task> insertTaskListAll = new List<Task>();
        for (Body053 b53 : b53List) {
            Task t = new Task();
            t.subject = b53.subject;
            t.Description = b53.Description;
            t.TaskSubtype = b53.TaskSubtype;
            t.Status = b53.Status;
            t.ActivityDate = b53.ActivityDate;
            String userId = userMap.get(b53.OwnerName);
            if (userId != null ) t.OwnerId = userId;
            if (b53.customer_code != null) {
                String customerId = accountMap1.get(b53.customer_code); 
                if (customerId != null) t.WhatId = customerId;
            } else if (b53.supplier_code != null ){
                String supplierId = accountMap2.get(b53.supplier_code); 
                if (supplierId != null ) t.WhatId = supplierId ;
            }
            insertTaskListAll.add(t);
        }
        
        if (insertTaskListAll.size() > 0) insert insertTaskListAll;
        
        String stIds = '';
        for (Task t : insertTaskListAll){
            if (stIds != '')  stIds = stIds + ',';
            stIds = stIds + t.Id;
        }
        
        return stIds;
    }



	global class Body053 {
        global String subject;
        global String Description;
        global String TaskSubtype;//Task
        global String OwnerName;
        global String Status;//Completed
        global Date ActivityDate;
        global String supplier_code;
        global String customer_code;
    }
    

}
```

そういえば、テストクラスはモック？　調べてみると自分で記事を書いていましたね。
リクエストだけなので、通常のテストクラスで大丈夫

+ [Apex RestRequest クラスを使ったテストコード](https://qiita.com/Keiji_otsubo/items/0ab1200ae518b8e4168f)

以下が最終的なテストクラスです。

```Java
@isTest
global class CreateTaskRESTAPI_test {
	// **************************************************
    // Jobcanの連携 クラス Taskを作る Apex REST API TEST            
    // K.Otsubo 2024/12/19
    //                    
    // **************************************************
    

    static testMethod void test_main(){
        User u = fkd_User.createTestUser();

        Account a = new Account();
        a.customer_code__c = '4038X';
        a.Name = 'test';
        insert a;
        
        CreateTaskRESTAPI.Body053 b = new CreateTaskRESTAPI.Body053();
        b.TaskSubtype = 'Task';
        b.subject = 'xxxxx';
        b.Status = 'Completed';
		b.OwnerName = 'K_otsubo@test.com';
        b.Description = 'JobcanId:3345';
        b.customer_code = '4038X';
        b.ActivityDate = Date.Today();
        
        List<CreateTaskRESTAPI.Body053> bList = new List<CreateTaskRESTAPI.Body053>();
        bList.add(b);
        
        Test.startTest();
        
        String xx = CreateTaskRESTAPI.doPost(bList); 
        
        Test.stopTest();
        
    }
```

よし、本番環境にこのApex REST APIもデプロイできました。
Postmanを使って処理したら、問題なく機能します。

次に指定ログインを作成してApexからコールアウトできるようにしましょう...

ここで思わぬエラーです。４時間かかっても解決しません。意味が分からんエラーですね。

+ [指定ログインでエラー](https://qiita.com/Keiji_otsubo/items/938196ba75975cb498dc)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/6ae66938-0efa-3087-7f0c-eab20dbb3936.png)
私がシステム管理者ですが、どこを見てもエラーの詳細は分かりませんでしたねぇ。本当に嫌になります。
Sandboxから別のSandboxへの指定ログインは機能しているのですが、Sandboxから本番環境は何故か機能しませんね。不思議です。

仕方ないのでApexでアクセストークンを取得して、ヘッダに入れてコールアウトできるようにします。
頼りになるのはノーコードのツールでなくてApexのコード。
コードを書いて先が見えました。

以下を書き忘れてエラーになりました。

```
req.setBody(stBody);
```

:::note alert
{"message":"POST requires content-length","errorCode":"UNKNOWN_EXCEPTION"}
:::


これが無いと思って追加しても別のエラーです。

```
req.setHeader('Content-Length',String.valueOf(stBody.length()));
```

:::note alert
FATAL_ERROR System.CalloutException: Read timed out
:::

この回答で分かりました。

https://salesforce.stackexchange.com/questions/354324/rest-post-account-record-error-post-requires-content-length


:::note
You have no body in req1, because you have set it to request.setBody(body);. You need to change this line to req1.setBody(body); and content-length will appear automatically on request with body. For POST it is required.
:::

確かに私もsetBodyが無かったですね。content-lengthは自動で計算されました。わざわざ設定しなくてもいい。


# Lotus Notesというレガシーからジョブカン、Salesforceの組み合わせで機能移行＋機能アップを行えます。

最後の最後で「指定ログイン」の謎エラーで嵌まってしまいました。
最近のシステムはノーコード、ローコードを歌い文句としていますが、一度エラーで嵌まると中身が分からないので対処が全くできません。

レガシーでは当然ですが、最後に頼りになるのはやはりコードです。コードが書ける環境ならエラー内容も取得できるのでまだ対策が考えられます。プラットフォームは進化してもコードが書けないと最終的にはダメだなという体験ができました。

またレガシーから新しいシステムに移行すると大変ですが、かなり勉強になりました。


# おまけ

件数が多いとコールアウト処理の時間が思ったより掛かります。次の処理を流すタイミングを見極めるために当面はハンドで実施しようと思いますが、直接プログラムを流すのも面倒なので、画面を作成してみました。かなり楽になりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328136/afe2626c-9589-c7e2-c048-bb5b064c9ae6.png)

消す処理（匿名ウィンドウから処理する）

Idが分かれば一括で消せます。

```Java
Set<String> IdSet = new Set<String>();
IdSet.add('00TIR00003LYkfg');


LIST<Task> delTaskLsit = new LIST<Task> ();

for (String myId : IdSet){
    Task t = new Task();
    t.Id = myId;
    delTaskLsit.add(t);
}

if (delTaskLsit.size() >0) delete delTaskLsit;
```



### メールが送信されなくなった...

+ [CRITICAL ALERT: Mail not routing after Domino restarts beginning December 13, 2024](https://support.hcl-software.com/community?id=community_blog&sys_id=4a7f878047a69e5033723c5c716d4379)
+ [CRITICAL ALERT: Mail not routing after Domino restarts beginning December 13, 2024](https://support.hcl-software.com/csm?id=kb_article&sysparm_article=KB0118192#:~:text=The%20HCL%20Domino%20Development%20team,rules%20will%20also%20start%20failing.)

# 関連

+ [コールアウトするApexバッチを時間起動で動かす](https://qiita.com/Keiji_otsubo/items/7f401ca52327c2bc92bb)