スマレジの外部会員連携をAWS Lambda + Google スプレッドシート でサーバレスに試してみる。
こんにちは!株式会社スマレジ 、開発部のmasaです。
今回は、D3.jsの触りの部分をやる予定だったんですが、ちょっと予定を変更して、 スマレジ の外部会員連携について、ご紹介したいと思います。 D3.jsの記事は次回 or 2回後くらいから始める予定です。
変更の理由
この機能、最近よくお問い合わせをいただくので、「やってみた記事」の一つでもあれば、 もう少しとっかかりやすくなるかなー、というだけです。深い意味はないです笑
外部会員連携とは
ざっくり言うと、スマレジ の会員マスタを使わない(スマレジ に会員情報を持たない)で、 外部の会員データを会員マスタとして扱う機能になります。
外部の会員マスタを参照する方式として、APIを利用する方式が取られています。 このAPIの仕様(スマレジ 側から、外部サービスにどんなリクエストが飛んできて、どう返せばいいのか)については、 スマレジ 管理画面にログイン後、下記のリンクを辿っていくか、[設定]->[システム連携]->[外部会員連携]をクリックすると、画像のようなページが開かれるので、そこから仕様書をダウンロードしてみてください。(サンドボックスをご利用の開発者様は下記リンクではなく、[設定]->[システム連携]->[外部会員連携]をクリックして、ダウンロードをお願いします。)
簡単に外部会員連携を試してみる。
連携イメージは分かったので、実際に試してみたい!だけど、今のCRMにつなげるのはちょっと不安 or 個人情報保護ポリシー的にNGで試せない。。。なんてことはあるんじゃないかなーと思います。
というわけで、ダミーデータをGoogle スプレッドシート で作成して、それを会員マスタに見立てて連携できるように作ってみます。
連携イメージは下記のような感じです。
今回はAWS Lambdaを利用しますが、GCPのCloud Functionsでも良いと思います。
「え、GASのエンドポイントを直接連携先に指定するんじゃダメなの?」と思われたかもしれません。 GASのdoGet()などで取得するデータは、一度302でリダイレクト情報が返され、リダイレクト先で結果を取得する仕組みになっており、 現状、スマレジ の外部会員連携は302リダイレクト方式には対応していないため、間に1レイヤー挟んでリダイレクト先からデータを取ってくることをしないといけないんです。そのためのLambdaというわけです。
Lambdaのソース
上述の、リダイレクト先から取ってくる、という部分は下記の記事を参考にさせていただきました。
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のコードで、一部不備があったので修正しています。