🖥

作ったものを晒すシリーズ#3: GASのWebアプリでSaaSと連携して業務を改善したい! ~boardを利用した請求書作成処理を添えて~

2023/11/10に公開

お久しぶりです

最後に前回の記事を投稿してから、もう既に思い出せないくらい時間が経ってしまいました...
皆さんはいかがお過ごしでしょうか?

自分は前回投稿してからというもの、書こう書こうと思いながらも音ゲーやソシャゲが楽しすぎて重い腰が上がらず放置してしまっていたんですが、これではいけないと奮い立ち、ようやく筆を執った次第です。

直近でネタとしても良さそうな実装をしたというのもあり、今回はその事について書きたいと思います。

この記事は一体?

つい最近の話ですが、「とあるサービスの請求書作成を自動化したい」というような要望がありました。

というのも、サービスの拡大に伴い加盟店数が増える中、やはりネックとなるのは手作業による工数増大と、ヒューマンエラーによる誤記載や誤送付です。
特にお金に関わるミスは会社の信頼に直結する為、少しでも人の手による誤作業を抑えたい…

という事で、請求書作成・管理SaaSの導入と合わせて、
APIによって、投入されたデータを元に自動で計算・SaaS連携・請求書作成までを自動化してしまおう!というのが今回作成した機能のコンセプトです。

で、折角なので、
GAS + WebAppでフロントとバックで情報やり取りしつつ、SaaS連携して業務効率化するという手法の一つの参考になればと思い、実装・技術面を抜粋して記事化しようと思います。
(フロントからバックエンドまで一貫して作るのって楽しいですね)

今回利用するサービス

今回は、以下のサービスを利用しました。

  • board
  • GAS(Google Apps Script)
    • clasp + Typescript

boardは、請求書作成や支払管理・顧客管理などの機能を纏めたSaaSです。
今回は、このboardが提供しているAPIを用いて、請求書の自動作成を実現します。

実装基盤については、もはやサーバーレス実装ではおなじみ(?)のGASを利用しています。
今回、元となるデータについてはcsvファイルで手動投入してもらう仕様で、

  • 基本的にファイルは Google Drive or Box に配置する決まりとなっている
  • csvを投入するのは実際に利用する現場の社員である事から、なるべく直感的である方が好ましい
  • 自社のGoogle Workspacesテナントで作るので、権限周りの設定やらセキュリティも意識する事が少なくて楽

などの理由から、GAS + Google Driveを基盤として作成することにしました。
割とGASって何でもできます。万能。GAS is God。

ちなみに、動作環境や開発環境の構成、実装~ に関しては基本一人での対応でした。

全体のフロー

当機能を利用するフローは、以下のような感じとなっています。
ユーザーが利用するWeb画面は、GASのウェブアプリによって作成・公開されています。
手軽にフロントもバックも作れるので、GAS様様ですね。

  1. 担当者は、事前に準備したcsvファイルを、指定されたGoogle Driveのフォルダに投入する
  2. 請求書作成処理実行用のWeb画面を開き、「請求書作成処理実行」ボタンを押下する
  3. csvの内容+αから必要な情報を計算し、boardのAPI経由で請求書が作成される
  4. 作成処理完了後、Slackの特定のチャンネル・ユーザーグループ宛に通知が届く
  5. 担当者は、Slack通知から作成処理結果を確認して請求書をboard上から送付する

2〜4までが、GASが担当する処理部分です。
1についても自動化できると本当はいいんですが、今回は手動で投入する運用となっています。

処理概要

全体のフローの番号に沿って、処理の概要を記載していきます。
業務内容に触れるような部分はボカして記載するので、分かりづらい部分あるかもしれません。

1. CSVをGoogle Driveのフォルダに投入する

業務担当者は、2種類のCSVをGoogle Driveの特定のフォルダに投入します。
詳細は伏せますが、

  • 対象ごとに、単価 × 件数で算出する項目が複数列記載されているCSV
  • 対象ごとに、固定金額の項目が記載されているCSV

の2種類があり、それぞれのCSV種別によって作成される請求書が分かれます。
ちなみに、片方のみに記載のある対象もあれば、両方に記載のある対象もあります。

2. 作成処理実行用Web画面

請求書作成処理は、処理実行用のWeb画面から担当者が直接実行する仕組みです。
この画面は、GASのウェブアプリによって作成しています。

フロント(Web画面)周りのコード

フロントのWeb画面実装は、index.htmlとjs,cssのファイルを分離して実装しています。
jsとcssのhtmlは、index.html内にて強制出力スクリプトレットにて読み込んで適用しています。

GASにおいてはjsもcssもhtmlファイルとして作成し、メインのhtml内で読み込む形が基本です。

index.html
<head>
    <base target="_top">
    <!-- 強制出力スクリプトレットにより、別ファイルに分離したcss.htmlファイルを読み込む -->
    <?!= HtmlService.createHtmlOutputFromFile('src/css').getContent(); ?>
</head><body>
・・・
<!-- 請求書作成処理実行ボタン -->
<button type="button" id="button_execute" class="btn btn-primary" onclick="onClickExecuteButton()">
・・・
<!-- 強制出力スクリプトレットにより、別ファイルに分離したjs.htmlファイルを読み込む -->
<?!= HtmlService.createHtmlOutputFromFile('src/js').getContent(); ?>
</body>

3. 作成処理フロー & コード

基本的な流れは以下のようになっています。
全体の処理はフロント側で実行され、Google DriveやAPI連携等、サーバー側で実行する必要がある処理については、サーバー側GASとして実装、フロント側から都度呼び出す仕組みです。
(コードの変数名などは一部ぼかす為に変更してます)

なぜこのような作りにしているかというと、1レコード分の処理が比較的長く、かつ何十~何百レコードと処理すると、サーバー側GASの動作許容時間をオーバーしてしまうからです。(SaaS側APIへのリクエスト・レスポンス時間、秒間リクエスト上限等の関係上)

上記のような背景もあり、フロント(ブラウザ)側で時間がかかる分には問題ない -> フロント側でループして1レコードずつ都度処理するようにしよう!という流れです。

  1. [フロント] 請求書作成処理ボタンを押下 > onClickExecuteButton() 実行
  2. [GAS] ロックファイル存在チェック
  3. [GAS] ロックファイル作成
  4. [GAS] 各種データをDrive及びboardのAPI経由で取得
  5. [フロント] 1つ目のCSVのレコード分、フロント側でループ
    1. [GAS] 請求書作成処理を対象CSV種別で実行
  6. [フロント] 2つ目のCSVのレコード分、フロント側でループ
    1. [GAS] 請求書作成処理を対象CSV種別で実行
  7. [GAS] 請求書作成処理結果作成 & Slack通知を実行
  8. [GAS] ロックファイル削除

1. [フロント] onClickExecuteButton() 実行

請求書作成処理ボタンを押下時に呼び出す、フロント側で実行される関数です。
大枠の流れはここで実行されるので、ブラウザが途中で閉じられると死にます。
この中で、GAS側とデータをやり取りしながら処理が進みます。

ここがGASを用いてフロントとサーバーでデータをやり取りする肝の部分ですが、
基本的にはデフォルトで用意されている google.script.run() を利用することでフロントからサーバー側のGASのファンクションを呼び出せます。

ただし、今回はもろもろの処理フローの関係上同期的に処理を行いたい部分が多く、google.script.run()は非同期となるため一工夫必要です。google.script.run() をPromiseでラップした結果を返す gasRun() を実装して呼び出します。これにより、Promiseの結果を待ってawaitを利用して同期的に処理を書けるようになります。

通常の非同期処理のままだと、SuccessHandlerの実装やらなんやらで処理の見通しが悪くなるので、Promiseを返すことで連続的に書けて処理の流れが見渡しやすくなるのもメリット。

参考:330K INFO
GASのgoogle.script.runをPromise化する

参考:qiita
google.script.run が Promise を返すようにする

js.html
<script>
    /**
     * 請求書作成処理実行ボタン押下時に呼び出す.
     */ 
    async function onClickExecuteButton(){
        // メイン処理
        const progressMessageArea = document.getElementById("progress_message");
        const executeButton = document.getElementById("button_execute");

        // 作成処理結果格納用配列
        const createResult = [];
        ・・・
    }
</script>

2. [GAS] ロックファイル存在チェック

担当者が複数存在する場合、タイミング次第でWeb画面から同時実行できてしまう可能性を考慮し、処理が実行されたタイミングでロックファイルを作成し、その存在有無によって実行を抑制する仕組みを組み込んでいます。

処理開始時にロックファイルが存在する場合はそのまま処理を終了しています。

js.html
<script>
    async function onClickExecuteButton(){
        ・・・
        // ロックファイルチェック
        const lockFile = await gasRun("GetLockFile");
        if(lockFile){
            progressMessageArea.textContent = "* 現在別枠で処理が実行されています。他に実行している人がいないかご確認ください。";
            executeButton.removeAttribute("disabled");
            return;
        }
        ・・・
    }
</script>
LockFileUtils.ts
/**
 * ロックファイルを取得して返却する.
 * @returns ロックファイルのFile. 存在しない場合はundefined.
 */ 
function GetLockFile(): GoogleAppsScript.Drive.File | undefined {
    const appFolderId = PropertiesService.GetScriptProperties().getProperty(PROPERTIES_NAME_APP_FOLDER_ID);
    const appFolder = DriveApp.getFolderById(appFolderId);

    // ロックファイルが存在したら返却
    const file = appFolder.getFilesByName(LOCK_FILE_NAME);
    if(file.hasNext()){
        return file.next();
    }
}

3. [GAS] ロックファイル作成

ロックファイルが存在しない場合、ロックファイルを作成して多重実行を抑止します。

js.html
<script>
    async function onClickExecuteButton(){
        ・・・
        // ロックファイル作成
        await gasRun("CreateLockFile");
        ・・・
    }
</script>
LockFileUtils.ts
/**
 * ロックファイルを作成する.
 * 存在している場合は何もしない.
 */ 
function CreateLockFile() {
    const appFolderId = PropertiesService.GetScriptProperties().getProperty(PROPERTIES_NAME_APP_FOLDER_ID);
    const appFolder = DriveApp.getFolderById(appFolderId);

    // ロックファイルを作成(存在しない場合)
    const lockFile = GetLockFile();
    if(!lockFile){
        appFolder.createFile(LOCK_FILE_NAME, "");
    }
}

4. [GAS] 各種データの取得

Google Driveに格納されたcsvの情報や、boardから顧客情報の一覧を取得してきます。
Drive上のファイルを扱う場合は、サーバーサイドGASで処理が必要です。

js.html
<script>
    async function onClickExecuteButton(){
        ・・・
        // 処理に必要なデータを取得
        const result = await gasRun("PreparationData");
        ・・・
    }
</script>
PreparationData.ts
/**
 * フロント側から呼び出す、処理開始時の前処理.
 * 請求書作成処理で利用する各種データを、drive/boardから取得して返却する.
 */ 
function PreparationData():{
    csvArray1: string[][],
    csvArray2: string[][],
    clientList: BoardClientEntity[]
} | undefined {
    // CSV取得処理
    ・・・
    // boardから顧客リスト取得(REST API)
    ・・・

    // 取得した各種情報を返却
    return {
        csvArray1,
        csvArray2,
        clientList
    }
    
}

5 / 6. [フロント] CSVのレコード分、フロント側でループ

返却されたCSVの配列をもとに、フロント側で件数分ループ処理します。
ループ内では1レコード分の情報をサーバーサイド側に渡して、1レコードずつサーバー側で処理します。
つまり、1件の処理について1つのGASの処理タスクが実行される形です。
GAS側の実行履歴を見ると、レコード分の該当処理の履歴がずらーっと表示されています。

js.html
<script>
    async function onClickExecuteButton(){
        ・・・
        // 請求書作成処理 その1
        const arrayLength = result.csvArray1.length;
        if(arrayLength >=2){
            createResults.push("csvArray1処理結果","","","","","","","")
            result.csvArray.forEach((record,index)=>{
                // ヘッダ行は飛ばす
                if(index==0) {continue;}

                // ここでGAS側の請求書作成処理本体を呼び出す
                const results = await gasRun("createBoardInvoice", result.clientList,result.commissionArray[index],"処理種別1");

                // 処理結果を格納する
                for(const result of results){
                    createResults.push(result);
                }
            })
        }

        // 請求書作成処理 その2(csvArrayの変数以外はその1と同様)

        ・・・
    }
</script>

5-1 / 6-1. [GAS] 請求書作成処理を対象CSV種別で実行

請求書を作成するビジネスロジック本体処理です。
内容はバリバリ業務に直結するので省きますが、受け取ったboardの顧客情報・処理対象のレコード・どのCSVの請求書を作成するのかという作成種別を利用して、boardの請求書作成・更新APIに投げるデータを作成し、実際にAPIを実行する部分まで行います。

createBoardInvoice.ts
function createBoardInvoice(
    clientList:BoardClientEntity[],
    targetCsvRow:string[],
    createInvoiceType:CREATE_INVOICE_TYPE
){
    ・・・

    色々業務ロジックがたくさん

    ・・・
    
    // 作成処理結果をフロント側へ返却
    return registResults;

}

7. [GAS] 請求書作成処理結果作成 & Slack通知を実行

作成処理結果をGoogleスプレッドシートに出力して、結果をSlackに通知します。
この処理結果のスプレッドシートには、実際に作成されたレコード毎のboardの案件や請求書へのリンクも記載しているので、担当者が内容を確認して、そのままboardへアクセスできるような導線になっています。

FinishProcess.ts
function finishProcess(createResults[][]){
    // 実行日の結果格納用フォルダを作成する
    const resultFolder = DriveApp.getFolderById("フォルダのID")
    const forderName = // 実行日の日付をyyyyMMddHHmmssの形式でフォーマットしてフォルダ名にする
    const outputFolder = resultsFolder.createFolder(folderName);

    // 結果スプレッドシートを作成する
    const resultSheetFile = Drive.Files?.insert({
        title: `作成処理結果_${folderName}`,
        mimeType: MimeType.GOOGLE_SHEETS,
        parents: [{id:outputFolder.getId()}]
    },null,{supportsAllDrives:true});

    const resultSpreadSheet = SpreadSheetApp.openById(resultSheetFile.id as string)

    // csvファイルを退避する
    ・・・

    // Slackの通知処理を行う
    notificationCreateResultToSlack(resultSpreadSheet.getUrl());
}

8. [GAS] ロックファイル削除

処理が完了した最後の最後に、最初に作成したロックファイルを削除します。
ちなみに、この処理は途中でエラー終了した場合も実行する必要があるため、finally句の内部で実行します。

js.html
<script>
    async function onClickExecuteButton(){
        ・・・
        try{
            // メイン処理部分
            ・・・
        }catch(error){
            // エラー時の処理
            ・・・
        }finally{
            // 必ず最後にロックファイルを削除
            await gasRun("DeleteLockFile");
            executeButton.removeAttribute("disabled");
        }
        ・・・
    }
</script>
LockFileUtils.ts
/**
 * ロックファイルを削除.
 * 存在していない場合は何もしない.
 */ 
function DeleteLockFile() {
    // ロックファイルを削除(存在する場合)
    const lockFile = GetLockFile();
    if(!lockFile){
        lockFile.setTrashd(true);
    }
}

長い!

だいぶ端折ったのに、めっちゃ長いですね、、、
実際には、コード自体は更に長いです。ただ、なるべく管理しやすくなるように、サーバーサイドGAS側については適度にファイルを分割しています。

作ってみた所感

GAS、やっぱり便利ですね(小並感)

一回の処理の実行時間に制限があったり、フロント側の実装方法に多少癖があったりと少々扱いづらい点はありますが、工夫次第で解決できる&事前に何も準備しなくても思い立ったらフロントWeb + サーバーサイドの構成を思い立ったら作り始められるのは魅力です。

何より、google workspacesの情報資産を簡単に扱えるのは、google workspacesを導入している企業としては圧倒的にメリット。
今回の実装においても、csvファイルを現場担当者が投入する上でどのような形が直感的か?結果を出力する形式はどのような形が見やすいか?という考慮において、普段現場でも利用しているgoogle drive上に配置する、スプレッドシートで出力するという手法が簡単に取れたのはそのおかげです。

ということで

みんな、GASをもっと活用してEnjoyしよう!
...じゃなかった、もっと業務を効率化しよう!

Discussion