株式会社スマレジの開発部でスマレジのサーバサイドを作っています

スマレジの外部会員連携をAWS Lambda + Google スプレッドシート でサーバレスに試してみる。

こんにちは!株式会社スマレジ 、開発部のmasaです。

今回は、D3.jsの触りの部分をやる予定だったんですが、ちょっと予定を変更して、 スマレジ の外部会員連携について、ご紹介したいと思います。 D3.jsの記事は次回 or 2回後くらいから始める予定です。

変更の理由

この機能、最近よくお問い合わせをいただくので、「やってみた記事」の一つでもあれば、 もう少しとっかかりやすくなるかなー、というだけです。深い意味はないです笑

外部会員連携とは

ざっくり言うと、スマレジ の会員マスタを使わない(スマレジ に会員情報を持たない)で、 外部の会員データを会員マスタとして扱う機能になります。

f:id:masa2019:20211123164204p:plain
外部会員連携のイメージ

外部の会員マスタを参照する方式として、APIを利用する方式が取られています。 このAPIの仕様(スマレジ 側から、外部サービスにどんなリクエストが飛んできて、どう返せばいいのか)については、 スマレジ 管理画面にログイン後、下記のリンクを辿っていくか、[設定]->[システム連携]->[外部会員連携]をクリックすると、画像のようなページが開かれるので、そこから仕様書をダウンロードしてみてください。(サンドボックスをご利用の開発者様は下記リンクではなく、[設定]->[システム連携]->[外部会員連携]をクリックして、ダウンロードをお願いします。)

www1.smaregi.jp

f:id:masa2019:20211123164545p:plain
仕様書のリンク

簡単に外部会員連携を試してみる。

連携イメージは分かったので、実際に試してみたい!だけど、今のCRMにつなげるのはちょっと不安 or 個人情報保護ポリシー的にNGで試せない。。。なんてことはあるんじゃないかなーと思います。

というわけで、ダミーデータをGoogle スプレッドシート で作成して、それを会員マスタに見立てて連携できるように作ってみます。

連携イメージは下記のような感じです。

f:id:masa2019:20211123165316p:plain
連携イメージ

今回はAWS Lambdaを利用しますが、GCPのCloud Functionsでも良いと思います。

「え、GASのエンドポイントを直接連携先に指定するんじゃダメなの?」と思われたかもしれません。 GASのdoGet()などで取得するデータは、一度302でリダイレクト情報が返され、リダイレクト先で結果を取得する仕組みになっており、 現状、スマレジ の外部会員連携は302リダイレクト方式には対応していないため、間に1レイヤー挟んでリダイレクト先からデータを取ってくることをしないといけないんです。そのためのLambdaというわけです。

Lambdaのソース

上述の、リダイレクト先から取ってくる、という部分は下記の記事を参考にさせていただきました。

blog.ikappio.com

const https = require('https');
const querystring = require('querystring');

const retrieveResourse = async (postData) => {
    
    let retryCount = 1;
    const MAX_RETRY = 5;
    let response = null;
    let queryParams = "";
    if (postData.length > 0) {
        queryParams = "?req=" + postData;
    }
    let url = "https://script.google.com/macros/s/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/exec" + queryParams; // GCP側のエンドポイントを設定してください。
    while(true) {
        if(retryCount > MAX_RETRY) {
            // 5回やってダメなら諦める
            response = { success: false, reason: "Retry Count Exceeded"};
            break;
        }

        // カウントの更新
        retryCount++;

        const promise = new Promise((resolve, reject) => {
            https.get(url, (res) => {
                const { statusCode } = res;
                console.log(statusCode);
                if(statusCode === 302) {
                    // リダイレクトするのでもう一度ループする。
                    resolve({
                        statusCode,
                        url: res.headers.location,  // リダイレクト先のURL
                    });
                    return;
                }
                if(statusCode === 200) {
                    // データを受け取り終了
                    let data = "";
                    res.setEncoding("utf8");
                    res.on("data", (chunk) => {
                        console.log(chunk);
                        data += chunk;
                    });
                    res.on("end", () => {
                        resolve({
                            statusCode,
                            data,
                        });
                        return;
                    });
                    return;
                }
                // 他のステータスコードは想定外
                resolve({
                    statusCode,
                });
            });
        });

        // 上のpromiseの終了を待つ。
        const result = await promise.catch((reason) => {
            // レスポンス以外のエラー(get自体に失敗など)
            return { statusCode: 0, reason }
        });

        if(result.statusCode === 302) {
            // もう1回ループする必要あり
            url = result.url;
            continue;
        }
        if(result.statusCode === 200) {
            // 取得できた
            response = {
                success: true,
                data: result.data,
            };
            break;
        }
        // エラーハンドリングする
        console.log("不明なエラー");
        // 一応リトライ
        continue;
    }
    return response;
}

exports.handler =  async function(event, context){
    console.log(event["body-json"])
    const requestBodyString = event["body-json"]

    const res =  await retrieveResourse(requestBodyString)
    console.log(res.data)
    if (res !== null && res.success) {
        console.log(res.data)
        return JSON.parse(res.data)
    } else {
        console.log("取得失敗:", res.reason)
    }
};

GASのソース

今回は、手軽に試せるContainer Bound Scriptで作成しています。

/**
 * メイン処理(GET)
 */
function doGet(e) {
  console.log(e);
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("会員マスタ");
  const params = e.parameter.req;
  let resData = "";
if (params.match("searchString")) {
  let tmpArray = params.split('=');
  resData = doSearch(tmpArray[1]);
} else if (params.match("customerCode")) {
  let tmpQueryArray = params.split('&');
  for (let i = 0; i < tmpQueryArray.length; i++) {
    if (!tmpQueryArray[i].match("customerCode")) {
      continue;
    }
    let tmpArray = tmpQueryArray[i].split('=');
    resData = doSearchDetail(tmpArray[1]);
    break;
  }
} else {
  sheet.getRange("B3").setValue("不正");  
}

  let responce = ContentService.createTextOutput();
  responce.setMimeType(ContentService.MimeType.JSON);
  responce.setContent(JSON.stringify(resData));
  return responce;
}

/**
 * 会員検索処理
 * @var string searchString 検索文字列
 * @return object[] 会員一覧
 */
function doSearch(searchString, division) {
  const ss = SpreadsheetApp.getActiveSpreadsheet()
  const sheet = ss.getSheetByName("会員マスタ");
  const customerList = sheet.getDataRange().getValues();  
  const customers = [];
  let currentCustomer = [];
  let currentCustomerName = "";
  for (let i = 0; i < customerList.length; i++) {
    if (i < 1) {
      continue;
    }
    currentCustomer = customerList[i];
    currentCustomerName = currentCustomer[4] + currentCustomer[5];
    if (!currentCustomerName.match(searchString)) {
      continue;
    }
    customers.push(
      {
        customerId: currentCustomer[0],
        customerCode: currentCustomer[1],
        lastName: currentCustomer[4],
        firstName: currentCustomer[5],
        status: currentCustomer[40]
      }
    );
  }
  let res = [];
  if (customers.length > 0) {
    res = {
      "result": {
          "count": customers.length,
          "customers": customers
      }
    };
  } else {
    res = {
      "result": {},
      "error": {
        "message": "検索条件に合致する会員は存在しませんでした。"
      }
    }
  }
  return res;
}

/**
 * 会員取得処理
 * @var string customerCode 会員コード
 * @return object 会員取得結果
 */
function doSearchDetail(customerCode) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const customerMaster = ss.getSheetByName("会員マスタ");
  const customerList = customerMaster.getDataRange().getValues();  
  const customerAlignmentMaster = ss.getSheetByName("連携情報マスタ");
  const customerAlignmentList = customerAlignmentMaster.getDataRange().getValues();  
  let customer = {};
  let currentCustomer = [];
  let currentCustomerCode = "";
  for (let i = 0; i < customerList.length; i++) {
    if (i < 1) {
      continue;
    }
    currentCustomer = customerList[i];
    currentCustomerCode = currentCustomer[1];
    if (!currentCustomerCode.match(customerCode)) {
      continue;
    }   
    for (let j = 1; j < customerAlignmentList.length; j++) {
      if (customerAlignmentList[j][2] !== 1) {
        continue;
      }
      if (currentCustomer[j - 1].length < 1) {
        continue;
      }
      customer[customerAlignmentList[j][1]] = currentCustomer[j - 1];
    }
  }
  let res = [];
  if (customer !== "") {
    res = {
      "result": customer
    };
  } else {
    res = {
      "result": [],
      "error": {
        "message": "指定の会員コードを持つ会員は存在しませんでした。"
      }
    }
  }
  return res;
}

また、取り込むスプレッドシートの構造は下記のようなものを想定しています。 customerの配列の添字は、下記の順番をベースにしています。

会員マスタ

会員ID 会員コード 会員番号 所属店舗 フリガナ(姓) フリガナ(名) アルファベット氏名 国籍 旅券番号 郵便番号 住所 電話番号 FAX番号 携帯電話番号 メールアドレス メールアドレス2 メールアドレス3 性別(0:不明, 1:男, 2:女) 生年月日 会員ランクコード 会員ランク名 会社名 部署名 役職 社員ランクコード 社員ランク名 ポイント付与単位(金額) ポイント付与単位(ポイント) ポイント ポイント期限 マイル 最終来店日時 入会日 退会日 案内メール受取許可フラグ (0:拒否, 1:許可) 備考 備考2 PINコード 会員状態区分 (0:利用可, 1:利用停止, 2:紛失, 3:退会, 4:名寄せ)
1 test001 1 テスト ユーザ テスト ユーザ USER TEST JPN 4000014 山梨県甲府市府中町 412345678 412349876 9012345678 test@test.co.jp 0 1980/01/01 1 ブロンズランク 株式会社須磨商事 レジ事業部 一般 100 2 0 2021/11/21 1 テストユーザです。 テストユーザです。(備考2) 20211121001 0
2 test002 2 hoge fuga ホゲ フガ HOGE HUGA JPN 4000014 山梨県甲府市府中町 412345678 412349876 9012345678 test@test.co.jp 0 1980/01/01 1 ブロンズランク 株式会社須磨商事 レジ事業部 一般 100 2 0 2021/11/21 1 テストユーザです。 テストユーザです。(備考2) 20211121001 0

連携情報マスタ(連携する項目を選ぶ)

項目名 物理名 連携区分(0:連携しない,1:連携する)
会員ID customerId 1
会員コード customerCode 1
会員番号 customerNo 1
所属店舗 storeId 1
lastName 1
firstName 1
フリガナ(姓) lastKana 1
フリガナ(名) firstKana 1
アルファベット氏名 非対応 0
国籍 非対応 0
旅券番号 非対応 0
郵便番号 postalCode 1
住所 address 1
電話番号 phoneNumber 1
FAX番号 faxNumber 1
携帯電話番号 mobileNumber 1
メールアドレス mailAddress 1
メールアドレス2 非対応 0
メールアドレス3 非対応 0
性別(0:不明, 1:男, 2:女) sex 1
生年月日 birthDate 1
会員ランクコード rank 1
会員ランク名 非対応 0
会社名 非対応 0
部署名 非対応 0
役職 非対応 0
社員ランクコード staffRank 1
社員ランク名 非対応 0
ポイント付与単位(金額) pointGivingUnitPrice 1
ポイント付与単位(ポイント) pointGivingUnit 1
ポイント point 1
ポイント期限 pointExpireDate 1
マイル mile 1
最終来店日時 lastComeDateTime 1
入会日 entryDate 1
退会日 leaveDate 1
案内メール受取許可フラグ (0:拒否, 1:許可) 非対応 0
備考 note 1
備考2 note2 1
PINコード pinCode 1
会員状態区分 (0:利用可, 1:利用停止, 2:紛失, 3:退会, 4:名寄せ) status 1

連携区分については、非対応な項目以外は連携する設定にしています。 ご自身の環境に合わせて、設定値の変更をお願いします。

API Gatewayについては、CDK or Terraterm化できてないので割愛させてください汗。ダイジェストで載せますと、

  • 統合タイプはLambda関数
  • マッピングテンプレートにapplication/x-www-form-urlencodedを指定して、「メソッドリクエストのパススルー」を設定
  • エラーレスポンスについては、仕様書に従って、400エラーの設定をお願いします。

という感じです。(コード化できたら、追記します)

また、今回は軽くお試ししてみるのが主題なので、シートが壊れてた時や、細かいエラーチェックは入れていませんのでご注意ください。 (入れるとコード量増えてわかりづらくなるので)

12/17追記

GASのコードで、一部不備があったので修正しています。