16
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

LINE Messaging API + LINE Pay API + GASで、昼食弁当注文システムのプロトタイプを作ってみた

Last updated at Posted at 2021-11-07

##はじめに
※これは、LINE BOTとLINE Payで何かできないかと思って作った架空のサービスのプロトタイプです。

みなさん、オフィスや現場で昼食はどうされていますか?

お弁当持参の方、コンビニに買いに行く方、外食される方、
あと、オフィスにお弁当屋さんが配達してくれるお弁当を食べてる方も多いことでしょう。

今回はオフィスにお弁当屋さんが配達してくれるケースの課題に着目したプロトタイプを作ってみました。

##現状と課題

オフィスにお弁当屋さんが配達してくれるケースは、一般的には以下の流れではないでしょうか。

オフィスの食堂には、社員の名前が記載された1ヶ月分の弁当スケジュール表が張り出されていて、各人が○×を記入します。

(イメージ図)
注文表

毎朝、お弁当屋さんは契約企業のオフィスに電話をかけて、「今日のお弁当の数」をきき、お昼前に配達します。
お弁当の代金は、月末締め翌月給料日にまとめて支払うことになってます。
月末になると、お弁当屋さんは、1ヶ月分を締めて、会社へ請求書を送ります。
会社の事務員は、従業員の給料引き落としの処理をして、給料日にお弁当屋さんへ代金を振り込みします。

これらの関係を図にすると以下になります。
お弁当屋さん、会社の事務員さんともに、面倒な事務があります。

Image from Gyazo

##解決方法
以上の面倒な事務を効率化するための方法を考えました。

- 直接、従業員がLINEのチャットボットで注文し、LINEPayで決済する。

  • チャットボットの注文は自動的にGoogleスプレッドシートに記録される。
解決方法

これで、お弁当屋さんと会社の事務員さんの事務負担が軽減されると思います。

##作ったもの
Image from Gyazo

###流れ
Image from Gyazo

Image from Gyazo

##システム構成図
Image from Gyazo

##開発環境
node.js v14.5.0
Heroku

##まずすること

####LINE公式アカウント作成
こちらを参考にしました。
LINE公式アカウントの作り方|開設の設定と運用方法

####LINE BOTの 設定
こちらを参考にしました。
1時間でLINE BOTを作るハンズオン (資料+レポート) in Node学園祭2017

####LINEリッチテキストメニューの設定
こちらを参考にしました。
【公式】リッチメニューの活用法│作成から設定方法まで解説

LINE Official Account Managerから、該当のアカウントを選択し、右側のトークルーム管理 > リッチメニュー
Image from Gyazo

####LINE Pay Sandbox (テスト用のLINE PAY)の設定
こちらにアクセスして設定
https://pay.line.me/jp/developers/techsupport/sandbox/creation?locale=ja_JP
テストID、パスワードがメールで送られてくる。

LINE Payコンソール(メール記載のリンク先) > 決済連動管理 > 連動キー管理
Channel ID と Channel Secret Keyを確認

Image from Gyazo

####Googleスプレッドシートの作成・設定
データベースとしてGoogleスプレッドシートを使いました。
Googleスプレッドシートの導入部分はこちらを参考
Node.js googleapis npmパッケージで Google スプレッドシートを await/async で読み取るメモ 〜1ft-seabass.jp.MEMO

こんなシート
Image from Gyazo

##コード

###LINE BOT

Githubで公開してます。
https://github.com/tatsuya1970/linepayTest

main.js
"use strict"
require("dotenv").config();

const server = require("express")();
const cache = require("memory-cache");
const debug = require("debug")("pay-test");

// Importing LINE Pay API SDK
const linePay = require("line-pay");
const pay = new linePay({
    channelId: process.env.LINE_PAY_CHANNEL_ID,
    channelSecret: process.env.LINE_PAY_CHANNEL_SECRET,
    hostname: process.env.LINE_PAY_HOSTNAME,
    isSandbox: true // なぜかこれをいれたら通った
});

// Importing LINE Messaging API SDK
const lineBot = require("@line/bot-sdk");
const botConfig = {
    channelAccessToken: process.env.LINE_BOT_ACCESS_TOKEN,
    channelSecret: process.env.LINE_BOT_CHANNEL_SECRET
}
const bot = new lineBot.Client(botConfig);
server.listen(process.env.PORT || 5000);

//Googleスプレッドシートを使う設定
let {google} = require('googleapis');
const creds = {
    "type": "service_account",
    "project_id": process.env.PROJECT_ID,
    "private_key_id": process.env.PRIVATE_KEY_ID,
    "private_key": process.env.PRIVATE_KEY,
    "client_email": process.env.CLIENT_EMAIL,
    "client_id": process.env.CLIENT_ID,
    "auth_uri": process.env.AUTH_URI,
    "token_uri": process.env.TOKEN_URI,
    "auth_provider_x509_cert_url": process.env.AUTH_PROVIDER_X509_CERT_URL,
    "client_x509_cert_url": process.env.CLIENT_X509_CERT_URL
  };

  let jwtClient = new google.auth.JWT(
    creds.client_email,
    null,
    creds.private_key.replace(/\\n/g, '\n'),  ////環境変数をenvにしたときのグーグルではうまくいかないのでこうしたhttps://qiita.com/Horie1024/items/d65abeebaf803beae18b
    ['https://www.googleapis.com/auth/spreadsheets',
     'https://www.googleapis.com/auth/drive']
  );

  
const sheet = process.env.SHEET_ID; //GoogleスプレッドシートのシートID
// スプレッドシートAPIはv4を使う
let sheets = google.sheets('v4');

//LINEスタンプのデータ https://developers.line.biz/ja/docs/messaging-api/sticker-list/
const packageId = [    8515, 446, 446,    6359,   11537];
const stickerId = [16581243,1989,1997,11069856,52002736];

server.post('/webhook', lineBot.middleware(botConfig), (req, res) => {
   console.log(req.body.events);
        //ここのif分はdeveloper consoleの"接続確認"用なので削除して問題ないです。
        if(req.body.events[0].replyToken === '00000000000000000000000000000000' && req.body.events[1].replyToken === 'ffffffffffffffffffffffffffffffff'){
            res.send('Hello LINE BOT!(POST)');
            console.log('疎通確認用');
            return; 
        }
        Promise
          .all(req.body.events.map(handleEvent))
          .then((result) => res.json(result));
});


// Webhook for Messaging API.
async function handleEvent(event) {
    
    if (event.type == "postback"){
        if (event.postback.data == "yes"){
            let reservation = {
                productName: "日替わり弁当 ※テストですので実際の支払いは発生しません",
                amount: 1,
                currency: "JPY",
                confirmUrl: process.env.LINE_PAY_CONFIRM_URL || `https://${req.hostname}/pay/confirm`,
                confirmUrlType: "SERVER",
                orderId: `${event.source.userId}-${Date.now()}`
            }
            // Call LINE Pay reserve API.
            pay.reserve(reservation).then((response) => {
                reservation.transactionId = response.info.transactionId;
                reservation.userId = event.source.userId;
                cache.put(reservation.transactionId, reservation);

                let message = {
                    type: "template",
                    altText: "LINE Payでのお支払いになります",
                    template: {
                        type: "buttons",
                        text: "LINE Payでのお支払いになります。\n※テストですので実際の支払いは発生しません",
                        actions: [
                            {type: "uri", label: "了解", uri: response.info.paymentUrl.web},
                        ]
                    }
                }
                // Now we can provide payment URL.
                return bot.replyMessage(event.replyToken, message);
            }).then((response) => {
                return;
            });
        } else {
            // User does not purchase so say good bye.
            let message = {
                type: "text",
                text: "次回のご利用をお待ちしております"
            }
            return bot.replyMessage(event.replyToken, message).then((response) => {
                cache.del(event.source.userId);
                return;
            });
        }
    }

    //GoogleスプレッドシートのJSON Web Token(JWT) の認証
    let resultJwtClient;
    try {
      resultJwtClient = await jwtClient.authorize();
    } catch (error) {
      console.log("Auth Error: " + error);
    }

    //会員リストを読み込む
    let responseGetSheet;
    try {
        responseGetSheet =  await sheets.spreadsheets.values.get({
            auth: jwtClient,
            spreadsheetId: sheet,
            range: "会員リスト",
        });
    } catch (error) {
        console.log('The API returned an error: ' + error);
    }
    //シートから読み込んだデータ
    let range = responseGetSheet.data.range;
    let majorDimension = responseGetSheet.data.mejorDimension;
    let values = responseGetSheet.data.values;

    let userId = event.source.userId; //LINEのuerId

    //カンマを含む場合の処理(会員情報新規・変更)
    if(event.type == "message" && event.message.text.indexOf('') != -1){
        let firstman =1; //初めてかどうか
        let editRow = "";
        let i = 0; 
        //for文の i=2 は、3行目からという意味
        for (i = 2; i < values.length; i++) {
            if (values[i][0] == userId){
                firstman = 0;
                editRow = String(i+1);
                console.log("editRow:%s",editRow);
                break;
            }
        } 
        let input = event.message.text.split('');
        let message = "";
        if (firstman == 1){
            message = {
                type: "text",
                text: input[0]+""+input[1]+"様で受け付しました"
            }
            appendData("会員リスト!A1",event.source.userId,input[0],input[1])
        }
        if (firstman == 0){
            message = {
                type: "text",
                text: input[0]+""+input[1]+"様に修正しました"
            }
            let range="会員リスト!A"+editRow;
            console.log("range:%s",range);
            updateData(range,event.source.userId,input[0],input[1])
        }
     return bot.replyMessage(event.replyToken, message);
    }
    let firstman =1; //初めてかどうか
    //for文の i=2 は、3行目からという意味
    for (let i = 2; i < values.length; i++) {
        console.log(values[i][0]);
        if (values[i][0] == userId){
            firstman = 0;
            break;
        }
    }
    if (firstman == 1){
        let message = {
            type: "text",
            text: "「支店名、氏名」を入力してください\n(入力例)広島支店、山本浩二\n※支店名と氏名の間に、全角カンマ「、」を入れてください"
        }
        return bot.replyMessage(event.replyToken, message);
    }

    if (event.type == "message") {
        let message = "";
        if(event.message.text === "注文"){
            message = {
                type: "template",
                altText: "注文",
                template: {
                    type: "confirm",
                    text: "日替わり弁当を注文しますか?",
                    actions: [
                        {type: "postback", label: "はい", data: "yes"},
                        {type: "postback", label: "いいえ", data: "no"}
                    ]
                }
            }
        }
        if(event.message.text === "今日の弁当"){
            message = [{
                type: "text",
                text: "今日は唐揚げ弁当です"
            },
            {
                type: "image",
                //今日の弁当の画像の保存場所
                originalContentUrl: "https://brali-image.s3-ap-northeast-1.amazonaws.com/images/bento-img.jpg",
                previewImageUrl: "https://brali-image.s3-ap-northeast-1.amazonaws.com/images/bento-img.jpg"
            }]
            
        }
        if(event.message.text === "注文取消"){     
            message = {
                type: "text",
                text: "注文を取消ししました(未実装)"
            }
        }
        if(event.message.text === "属性修正"){
            message = {
                type: "text",
                text: "「支店名、氏名」を入力してください\n(入力例)広島支店、山本浩二\n※支店名と氏名の間に、全角読点「、」を入れてください"
            }
        }
        return bot.replyMessage(event.replyToken, message);
    }      
}


// If user approve the payment, LINE Pay server call this webhook.
server.get("/pay/confirm", (req, res, next) => {
    if (!req.query.transactionId){
        return res.status(400).send("Transaction Id not found.");
    }
    // Retrieve the reservation from database.
    let reservation = cache.get(req.query.transactionId);
    if (!reservation){
        return res.status(400).send("Reservation not found.")
    }
    let confirmation = {
        transactionId: req.query.transactionId,
        amount: reservation.amount,
        currency: reservation.currency
    }
    return pay.confirm(confirmation).then((response) => {
        res.sendStatus(200);

        //スプレッドシートに注文した人の情報を送信
        let userId = reservation.userId;       
        appendData("今日の注文!A1",userId,"","");
        //スタンプをランダムに選ぶ
        let rand = Math.floor(Math.random() * (packageId.length));

        let messages = [{
            type: "sticker",
            packageId: packageId[rand],
            stickerId: stickerId[rand]
        },{
            type: "text",
            text: "お買い上げありがとうございました"
        }]
        return bot.pushMessage(reservation.userId, messages);
    }).then((response) => {
        cache.put(reservation.userId, {subscription: "active"});
    });
});

//googleスプレッドシートにデータを追加(行を追加)
async function appendData(range0,value1,value2,value3) {
    let responseAppendSheet;
      try {
        responseAppendSheet = await sheets.spreadsheets.values.append({
        auth: jwtClient,
        spreadsheetId: sheet,
        range: range0,
        valueInputOption: "USER_ENTERED",
        insertDataOption : "INSERT_ROWS",
        resource : {
          values : [[value1,value2,value3]]
        }
      });
    } catch (error) {
       console.log('The API returned an error: ' + error);
    }
}

//googleスプレッドシートのデータをアップデート(上書き)
async function updateData(range0,value1,value2,value3) {
    let responseAppendSheet;
      try {
        responseAppendSheet = await sheets.spreadsheets.values.update({
        auth: jwtClient,
        spreadsheetId: sheet,
        range: range0,
        valueInputOption: "USER_ENTERED",
        resource : {
          values : [[value1,value2,value3]]
        }
      });
    } catch (error) {
        console.log('The API returned an error: ' + error);
    }
}

###Googleスプレッドシート側
注文が入ってきたら、ユーザーIDからお客様情報を自動的にシートに書き出すことをGoogle Apps Script(GAS)でやってます。

function myFunction() {
  
  const mySheet = SpreadsheetApp.getActiveSheet(); //シートを取得

  //ほかからの干渉は挿入になり、getActiveCellでは「Exception: 範囲外のセル参照です」というエラーになる
  //getCurrentCell()でとる。
  //トリガーは「変更」、「修正」では反応しない

  //他のカラムに入力するのは無視
  if (mySheet.getCurrentCell().getColumn() != 1 ){ 
    return;
  }

  const myRow = mySheet.getCurrentCell().getRow() 
  const myCell = mySheet.getRange(myRow,1);
  const orderID = myCell.getValue();
  const memberlistSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("会員リスト");

  let orderCompany;
  let orderName;

  //アクティブセルがシート「今日の注文」かを判定
  if( mySheet.getName()=="今日の注文" && myRow > 2 ){ 
      
      for (let i = 3; i <= memberlistSheet.getLastRow(); i++) {

          if(orderID == memberlistSheet.getRange(i,1).getValue()){
            orderCompany = memberlistSheet.getRange(i,2).getValue();
            orderName = memberlistSheet.getRange(i,3).getValue();
            break;
          }    
      }

    const color="#FFFFFF";
    mySheet.getRange(myRow, 1,  1, 4).setBackground(color);
    mySheet.getRange(myRow, 2).setValue(orderCompany);
    mySheet.getRange(myRow, 3).setValue(orderName);

    const date = new Date();
    const timestamp = Utilities.formatDate(date, 'JST', 'YYYY-MM-dd HH:mm:ss');
    mySheet.getRange(myRow, 4).setValue(timestamp);
  }

  if( mySheet.getName()=="会員リスト" && myRow > 2 ){ 
    const color="#FFFFFF";
    mySheet.getRange(myRow, 1,  1, 4).setBackground(color);
    const date = new Date();   
    const timestamp = Utilities.formatDate(date, 'JST', 'YYYY-MM-dd HH:mm:ss');
    mySheet.getRange(myRow, 4).setValue(timestamp);
  }

}

####参考にしたサイト

16
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?