Shoeisha Technology Media

CodeZine(コードジン)

記事種別から探す

Spread.ViewsとPayPalを連携して商品発送管理システムを作る

IoT時代の救世主! SpreadJSで作るデータ可視化アプリ 第4回

  • LINEで送る
  • このエントリーをはてなブックマークに追加

 本連載では、グレープシティが開発するJavaScriptライブラリ「SpreadJS」の 収録コントロール「Spread.Views」を活用して、IoT時代に役立つさまざまなアプリケーションを作っていきます。今回は、Spread.ViewsとPayPalやクリックポストなどの外部サービスを連携して、商品発送の管理をするシステムを作ってみます。

 こんにちは、dotstudio株式会社n0bisukeです。前回はSpread.Viewsのスパークライン機能とIoTデバイスを利用して、インフルエンザ対策のBIツールを作成しました。今回はdotstudioでの商品購入から発送までの流れを管理するシステムを作ります。

dotstudioでの商品発送フロー

 ざっくりいうと今回は業務改善を行うのですが、まずは現状の業務がどうなってるかを紹介します。

 dotstudioではNefry BTを始めとしたIoTデバイスの販売を行っています。小売店への卸売もありますが、半分くらいは自社サイトから買われていて、発送業務が定期的に発生します。

シーケンス

 現状の流れをシーケンス図のようなものにまとめてみました。各サービスについては次の項目で紹介しています。

 購入者がPayPal経由で商品を買うと、通知メールが購入者と販売者(dotstudio)両方に送られます。販売者は通知メールの情報を元に、クリックポストでラベル申込と決済、印字、パッケージングを行い、日本郵政で発送します。販売者は発送したら、メールで発送完了連絡を購入者に送ります。次の日以降で日本郵政から購入者に商品が届きます。ちなみに、早ければ2日以内で全ての流れが完了します。

利用サービスと現状課題

 2018年2月現在のdotstudioでの商品発送フローでは以下のツールを使っています。

PayPal

 おなじみの決済サービスです。

 dotstudioではPayPalボタンと呼ばれるボタンを設置するだけでEC機能を追加できるサービスを利用して、自社サイトにEC機能を追加しています。本当はカートシステムなども作りたいですが、もう少し商品が増えたら検討します。

 購入通知を行うAPIが存在しますが、現状はAPI連動などはできていません。

クリックポスト

 クリックポストは日本郵政が提供している発送サービスです。いわゆるメール便ですが、追跡番号があり一件当たりの配送コストも低いです。

 本当に最初は手書きでラベルを作っていましたが、その時代に比べるとかなり楽になりました。APIが存在しなく、手入力をしないといけないのがボトルネックです。

 現状だと、購入者が購入した際、PayPalから送信されてくる通知メールにある購入者情報を手作業でクリックポストのフォームに入力してラベル作成をします。

 また、作成したラベルは印字して、パッケージングなども行うのですがパッケージング作業のスマート化は今回は対象外になります。ロボットアームを作るか別途外部サービスの導入を検討中です。

メール

 発送完了時の通知メールを送るのですが、現状だとシステム化できていなく、手作業での配信となっています。

 一番自動化ができそうな部分なので、この機会にGmail APIを活用して、配送メールの自動化に挑戦してみたいと思います。

今回やること

 今回は、「グリッドレイアウトによるトレリス」のデモを元に商品購入があった際の商品の状態管理と、裏側のタスク自動化を連動させます。

 商品の各状態を管理できるTrello風のUIをSpread.Viewsで実現します。

 これだけでもだいぶありがたいのですが、裏側も各システムとの連動を図ります。具体的には以下の内容を連動させます。

  • 商品購入があったら通知して管理画面にカードを追加
  • カードを移動すると、商品購入者のデータを元にラベル作成を半自動化
  • カードを移動すると、商品発送の際に送るメールが送られる

 ざっくりと図にまとめるとこのような流れになります。

 実装範囲が多いですね。

実装

 それでは実際に作っていきます。サーバーサイドの言語はNode.js(v9.5.0)を利用します。

 また、筆者の環境はmacOS High Sierra(v10.13.1)となっています。

1. Node.jsでアプリケーションの土台となるサーバー構築

 まずは基本となるWebサーバーを立てていきます。

 ds_goods_managementというフォルダを作成し、その中で作業を進めます。

$ mkdir ds_goods_management
$ cd ds_goods_management
$ npm init -y

 また、前回記事同様にExpressを利用し、POSTリクエストを扱いやすくするbody-parserをインストールします。

 Expressは前回記事同様に執筆時点(2018年2月)ではまだα版ですが、今後5系がデファクトになる将来を見越して使ってみます。

 1月時点で5.0.0-alpha.3、2月時点で5.0.0-alpha.6ということで開発スピードが早いですね。

$ npm i --save express@5.0.0-alpha.6 body-parser

 server.jsindex.htmlpublicフォルダを作成し、server.jsindex.htmlにプログラムを記述していきます。

$ touch server.js
$ touch index.html
$ mkdir public
server.js
'use strict';

const express = require('express');
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.urlencoded({extended: false}));
app.use(express.static(__dirname + '/public'));

const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => res.sendFile(__dirname+'/index.html'));

app.listen(PORT);
console.log(`listening on *:${PORT}`);

 次にindex.htmlです。デモページにあるコードをコピペしましょう。

 ちなみに、現状のプロジェクトのファイル構成は以下のような感じです。

$ ls
index.html        package-lock.json public
node_modules      package.json      server.js

 サーバーを起動して動作確認をします。

$ node server.js
listening on *:3000

 サーバーが起動するので、ブラウザからhttp://localhost:3000/にアクセスするとこのようなページが表示されます。

2. デモのトレリスUIを動かす

 必要なファイルとフォルダを設置していき、デモを手元で動かしてみます。

 index.htmlのリソース読み込み箇所を参考に進めます。

index.html
省略

    <link rel="stylesheet" type="text/css" href="css/gc.spread.views.dataview.10.3.0.css">
    <link rel="stylesheet" type="text/css" href="css/bootstrap-snippet.min.css">
    <script src="js/gc.spread.common.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/gc.spread.views.dataview.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/locale/gc.spread.views.dataview.locale.ja-JP.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/plugins/gc.spread.views.gridlayout.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/plugins/gc.spread.views.trellisgrouping.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/zepto.min.js" type="text/javascript"></script>
    <script src="js/license.js" type="text/javascript"></script>
    <script src="data/timelineBoard.js" type="text/javascript"></script>

省略

 まずはpublicフォルダcssjsdataの三つのフォルダを作成します。

$ mkdir public/css
$ mkdir public/js
$ mkdir public/data
CSSファイル

 cssフォルダ内にbootstrap-snippet.min.cssgc.spread.views.dataview.10.3.0.cssを設置します。

JSファイル

 jsフォルダ内にgc.spread.common.10.3.0.min.jsgc.spread.views.dataview.10.3.0.min.jszepto.min.jslicense.jsを設置し、さらにlocalepluginsフォルダを作成します。js/localeフォルダ内にgc.spread.views.dataview.locale.ja-JP.10.3.0.min.jsを設置し、js/pluginsフォルダ内にgc.spread.views.gridlayout.10.3.0.min.jsgc.spread.views.trellisgrouping.10.3.0.min.jsを設置します。

テストデータのファイル

 dataフォルダ内にtimelineBoard.jsを設置します。このファイルは実データが入ってきたら削除して構いません。

 画像がない状態ですが、この状態でhttp://localhost:3000をリロードすると、トレリスUIができあがります。

 実際に動かすことも可能です。

3. トレリスで商品管理UIを作る

 ここから、実際に利用できる商品管理の画面にしていきます。

 まずは実際のデータを利用するため、サンプルデータの読み込み箇所である<script src="data/timelineBoard.js" type="text/javascript"></script>を削除します。

 また、DEMOではトレリスレイアウトとノーマルレイアウトの切り替えができる機能がありましたが、トレリスのみを利用するので削除します。

 以下の箇所を削除しましょう。

index.html
省略
        <div class="button-container">
            <button id="normal" type="button" class="btn btn-default" onclick="changeToNormalLayout()">Default</button>
            <button id="trellis" type="button" class="btn btn-default active" onclick="changeToTrellisLayout()">Trellis
        </button>
        </div>
省略

 次に画像を設置します。public/imagesフォルダを作成し、その中に商品となる画像と、background.pngを設置します。background.pngはデモリソースからコピーしてきましょう。

商品管理の状態定義を行う

 ここから、scriptタグ内を編集します。

 まずは商品管理の状態定義をします。'未発送','ラベル申込/決済_済','ラベル印字_済','パッケージング_済','発送完了','配送完了'の6つ状態を定義しました。

index.html
省略
      <script>
        //状態定義
        const workGroups = ['未発送','ラベル申込/決済_済','ラベル印字_済','パッケージング_済','発送完了','配送完了']; //ヘッダー情報


省略

 もともとのデモだと、存在するデータをもとに状態定義が行われ、データが空の状態だと扱いづらさがありました。

 ここの設定は「カードレイアウトによるトレリス」のデモソースが参考になりました。

描画用のテンプレートの用意

 次に、実際にデータが描画されるカード部分のテンプレートを用意します。こちらも住所用のdata-column="address"や郵便番号用のdata-column="address_zip"などの項目を追加しています。

index.html
省略
      <script>
      	 省略

      	 //カードのテンプレート
        const rowTemplate = `
        <div class="group-item-container">
            <div class="group-item-container-inner {{? it.progress==100}}finish{{?? it.progress>=80}}eighty-per{{?? it.progress>=50}}fifty-per{{?? it.progress>=30}}thirty-per{{??}}start{{?}}">
                <div data-column="title" class="group-item-title  {{? it.progress==100}}finish-head{{?? it.progress>=80}}eighty-per-head{{?? it.progress>=50}}fifty-per-head{{?? it.progress>=30}}thirty-per-head{{??}}start-head{{?}}"></div>
                <div data-column="photo" class="group-photo-container"></div>
                <div data-column="description" class="group-item-description"></div>
                <div data-column="address_zip" class="group-item-description">〒</div>
                <div data-column="address" class="group-item-description"></div>
            </div>
        </div>`;
        const photoPresenter = '<img class="employee-photo" src={{=it.photo}} />';

省略

購入者情報用のデータ定義とテンプレートとの紐付け

 次に、データ定義をします。titleやdescriptionなどのフィールドはデモのままで良いですが、購入者の住所や郵便番号のフィールドを追加しました。

 また、dataFieldの箇所がテンプレート側との紐付けになります。

index.html
省略
      <script>
      	 省略

        //データ定義
        const columns = [
            {
                id: 'title',
                name: 'title',
                dataField: 'title'
            }, {
                id: 'description',
                name: 'description',
                dataField: 'description'
            }, {
                id: 'photo',
                dataField: 'photo',
                presenter: photoPresenter
            }, {
                id: 'progress',
                dataField: 'progress'
            }, {
                id: 'address',
                name: 'address',
                dataField: 'address'
            }, {
                id: 'address_zip',
                name: 'address_zip',
                dataField: 'address_zip'
            }
        ];

省略

仮データの用意

 データの定義やテンプレートを用意しましたが、実データがまだなかったので仮データを用意します。

index.html
省略
      <script>
      	 省略

      	 //仮データ
        let data = [
            {"work": "未発送", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 0, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "未発送", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 0, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "ラベル申込/決済_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 20, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "ラベル印字_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 40, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "パッケージング_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 60, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "発送完了", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 80, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "配送完了", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 100, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
        ];

省略

描画する

 最後に、描画部分です。ここまでに用意した、定義情報、テンプレート、データをもとにトレリスの描画をします。

index.html
省略
      <script>
      	 省略

      	 //描画
        const TrellisGrouping = new GC.Spread.Views.Plugins.TrellisGrouping({panelUnitWidth: 190});

        const dataView = new GC.Spread.Views.DataView(document.getElementById('grid1'), data, columns, new GC.Spread.Views.Plugins.GridLayout({
            grouping: [{
                field: 'work',
                preDefinedGroups: workGroups,
                header: {height: 24}
            }],
            rowTemplate: rowTemplate,
            rowHeight: 150,
            groupStrategy: TrellisGrouping
        }));

	</script>
</body>
</html>

CSS調整

 画像の表示箇所調整でCSSも編集します。細かい調整はCSSを直接編集しましょう。

index.html
省略
	<style>
	省略

        .group-photo-container {
            position: absolute;
            top: 3.5em;
            right: 0.4em;
        }

    省略
	</style>
省略

ソースのまとめとここまでの完成画面

 ここまでできると、このようなUIが完成します。

 また、ここまでのindex.htmlが以下になります。

index.html
<!doctype html>
<html style="height:100%;font-size:14px;">

<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" type="text/css" href="css/gc.spread.views.dataview.10.3.0.css">
    <link rel="stylesheet" type="text/css" href="css/bootstrap-snippet.min.css">
    <script src="js/gc.spread.common.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/gc.spread.views.dataview.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/locale/gc.spread.views.dataview.locale.ja-JP.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/plugins/gc.spread.views.gridlayout.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/plugins/gc.spread.views.trellisgrouping.10.3.0.min.js" type="text/javascript"></script>
    <script src="js/zepto.min.js" type="text/javascript"></script>
    <script src="js/license.js" type="text/javascript"></script>
    <style>
        * {
            -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
        }

        html {
            position: fixed;
            width: 100%;
        }

        ::-webkit-scrollbar {
            width: 5px;
            height: 5px;
            border-radius: 5px;
        }

        ::-webkit-scrollbar-thumb {
            background: #d6dadc;
        }

        ::-webkit-scrollbar-corner {
            display: none;
        }

        .gc-grid {
            border: none;
            background: #fff url(images/background.png);
            color: #4d4d4d;
            padding: 0.4em;
        }

        .button-container {
            margin-bottom: 1em;
        }

        .employee-photo {
            width: 32px;
            height: 32px;
            border-radius: 50%;
        }

        .group-item-title {
            border: none;
            color: #f1f1f1;
            background: #C48C43;
            white-space: nowrap;
            text-overflow: ellipsis;
            padding: 0.4em;
            font-size: 16px;
        }

        .group-photo-container,
        .group-item-description {
            border: none;
        }

        .group-photo-container {
            position: absolute;
            top: 3.5em;
            right: 0.4em;
        }

        .group-item-description {
            padding: 8px;
        }

        .gc-trellis-group-header-inner {
            padding: 0 0.4em;
            font-size: 16px;
        }

        .group-item-container {
            height: 100%;
            font-size: 12px;
            overflow: hidden;
            position: relative;
        }

        .group-item-container-inner {
            height: 95%;
            border-radius: 4px;
            overflow: hidden;
            color: #f1f1f1;
            box-shadow: 0 3px 5px rgba(0, 0, 0, 0.10);
        }

        .finish {
            background: #603E50;
        }

        .eighty-per {
            background: #695877;
        }

        .fifty-per {
            background: #73738E;
        }

        .thirty-per {
            background: #7C9EAA;
        }

        .start {
            background: #8FC8C9;
        }

        .finish-head {
            background: #533747;
        }

        .eighty-per-head {
            background: #5F506B;
        }

        .fifty-per-head {
            background: #6A6B83;
        }

        .thirty-per-head {
            background: #76949F;
        }

        .start-head {
            background: #86BBBD;
        }

        .trellis-grouping .gc-trellis-group-header {
            line-height: 24px;
        }
    </style>
</head>

<body style="margin:0;position:absolute;top:0;bottom:0;left:0;right:0;font-size:14px;user-select:none;-webkit-user-select: none;overflow:hidden;">
    <div style="height: 100%; position: relative">
        <div style="height:90%;">
            <div id="grid1" style="height:100%;"></div>
        </div>
    </div>

    <script>
        //状態定義
        const workGroups = ['未発送','ラベル申込/決済_済','ラベル印字_済','パッケージング_済','発送完了','配送完了']; //ヘッダー情報

        //カードのテンプレート
        const rowTemplate = `
        <div class="group-item-container">
            <div class="group-item-container-inner {{? it.progress==100}}finish{{?? it.progress>=80}}eighty-per{{?? it.progress>=50}}fifty-per{{?? it.progress>=30}}thirty-per{{??}}start{{?}}">
                <div data-column="title" class="group-item-title  {{? it.progress==100}}finish-head{{?? it.progress>=80}}eighty-per-head{{?? it.progress>=50}}fifty-per-head{{?? it.progress>=30}}thirty-per-head{{??}}start-head{{?}}"></div>
                <div data-column="photo" class="group-photo-container"></div>
                <div data-column="description" class="group-item-description"></div>
                <div data-column="address_zip" class="group-item-description">〒</div>
                <div data-column="address" class="group-item-description"></div>
            </div>
        </div>`;
        const photoPresenter = '<img class="employee-photo" src={{=it.photo}} />';

        //データ定義
        const columns = [
            {
                id: 'title',
                name: 'title',
                dataField: 'title'
            }, {
                id: 'description',
                name: 'description',
                dataField: 'description'
            }, {
                id: 'photo',
                dataField: 'photo',
                presenter: photoPresenter
            }, {
                id: 'progress',
                dataField: 'progress'
            }, {
                id: 'address',
                name: 'address',
                dataField: 'address'
            }, {
                id: 'address_zip',
                name: 'address_zip',
                dataField: 'address_zip'
            }
        ];

        //仮データ
        let data = [
            {"work": "未発送", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 0, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "未発送", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 0, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "ラベル申込/決済_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 20, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "ラベル印字_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 40, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "パッケージング_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 60, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "発送完了", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 80, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
            {"work": "配送完了", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 100, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
        ];

        //描画
        const TrellisGrouping = new GC.Spread.Views.Plugins.TrellisGrouping({panelUnitWidth: 190});
        const dataView = new GC.Spread.Views.DataView(document.getElementById('grid1'), data, columns, new GC.Spread.Views.Plugins.GridLayout({
            grouping: [{
                field: 'work',
                preDefinedGroups: workGroups,
                header: {height: 24}
            }],
            rowTemplate: rowTemplate,
            rowHeight: 150,
            groupStrategy: TrellisGrouping
        }));
    </script>
</body>

</html>

4. ドラッグアンドドロップのイベントを取得する

 ここまでで、見た目の調整が出来たので、振る舞いを追加していきます。

 今回の仕組みの場合、ドラッグアンドドロップでカードを別の状態に動かしたときに、何かしら処理を行いたいです。

 Spread.ViewsのTrellisGroupingのドキュメントを見るとdragDroppingというイベントを取得できると書いてあります。これを使ってドラッグアンドドロップした際の挙動をコントロールします。イベントの取得に関してはグリッドイベントのデモが参考になります。

 まずはドラッグアンドドロップが行われた際に実行される関数dragDroppingFnを定義します。

 次にTrellisGrouping['dragDropping'].addHandler()のメソッドで定義した関数を紐付けます。

index.html
省略
      <script>
      	 省略

		//ドラッグアンドドロップ時に反応
       const dragDroppingFn = (sender,args) => {
            if(args.status !== 'beforeDropping') return; //ドロップ完了時のみ発火させる
            alert('ドロップ');
            console.log('----------');
            console.log(sender.dataSource_);
       };

       //ドラッグ&ドロップ時のイベントを追加
       TrellisGrouping['dragDropping'].addHandler(dragDroppingFn);

    </script>
</body>

</html>

 dragDroppingのイベントではドラッグ時(マウスで要素を動かし始めるタイミング)とドロップ時(マウスを離して要素を置いたタイミング)の二回のイベントが取得できます。args.statusの値がbeforeDraggingbeforeDroppingのどちらかになるので、どちらかを利用する際に条件分岐をさせます。

 今回でいうとif(args.status !== 'beforeDropping') return;の箇所でドラッグイベントを無視するようにして、ドロップ時のみ反応するようにしています。

 ページをリロードして確認するとドロップイベントとデータが確認できます。

5. サーバーサイドとデータ連動

 現状のデータはフロント側のJavaScriptに保存されていて、ページがリロードされると初期状態に戻ってしまいます。

 サーバーサイドでデータを保持して、ページがリロードされても状態が維持されるようにします。

サーバーを編集する

 Node.js側のコードを編集します。

 originDataという配列に、フロントエンドで利用していたデータを移行します。

server.js
'use strict';

const express = require('express');
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.urlencoded({extended: false}));
app.use(express.static(__dirname + '/public'));

const PORT = process.env.PORT || 3000;

let originData = [
    {"work": "未発送", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 0, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
    {"work": "ラベル申込/決済_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 20, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
    {"work": "ラベル印字_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 40, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
    {"work": "パッケージング_済", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 60, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
    {"work": "発送完了", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 80, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
    {"work": "配送完了", "description": "Nefry BT x 1", "title": "菅原のびすけ", "photo": "./images/ds.png", "progress": 100, "address": "東京都千代田区外神田2-9-3\nユニオンビル工新 8F", "address_zip":"101-0021"},
];

app.get('/', (req, res) => res.sendFile(__dirname+'/index.html'));

//データ取得のAPI
app.get('/getData', (req, res) => res.json(originData));

//データ更新のAPI
app.put('/update', (req, res) => {
    const updateData = JSON.parse(req.body.data);
    originData = updateData; //元データを更新
    console.log(originData);
    res.json(originData);
});

app.listen(PORT);
console.log(`listening on *:${PORT}`);

 また/getDataにアクセスするとoriginDataがJSON形式で取得できるAPIを作成します。

 Node.jsを再起動してhttp://localhost:3000/getDataにアクセスしてJSONが表示されることを確認しましょう。

 また、/updateにアクセスするとoriginDataが更新されるAPIも追加しています。

 こちらについてはクライアント側の説明をする際に触れます。

クライアントを編集する

 次はクライアント側です。データを/getDataにHTTPリクエストして取得します。HTTPのクライアントライブラリにはaxiosを利用します。https://unpkg.com/axios/dist/axios.min.jsを既存のscriptタグの前に読み込ませます。

index.html
省略
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script>
         //状態定義
        const workGroups = ['未発送','ラベル申込/決済_済','ラベル印字_済','パッケージング_済','発送完了','配送完了']; //ヘッダー情報
    	省略

 index.htmlは変更点が多いのでbodyタグ以下をまとめています。

index.html
省略

<body style="margin:0;position:absolute;top:0;bottom:0;left:0;right:0;font-size:14px;user-select:none;-webkit-user-select: none;overflow:hidden;">
    <div style="height: 100%; position: relative">
        <div style="height:90%;">
            <div id="grid1" style="height:100%;"></div>
        </div>
    </div>

    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script>
        //状態定義
        const workGroups = ['未発送','ラベル申込/決済_済','ラベル印字_済','パッケージング_済','発送完了','配送完了']; //ヘッダー情報

        //カードのテンプレート
        const rowTemplate = `
        <div class="group-item-container">
            <div class="group-item-container-inner {{? it.progress==100}}finish{{?? it.progress>=80}}eighty-per{{?? it.progress>=50}}fifty-per{{?? it.progress>=30}}thirty-per{{??}}start{{?}}">
                <div data-column="title" class="group-item-title  {{? it.progress==100}}finish-head{{?? it.progress>=80}}eighty-per-head{{?? it.progress>=50}}fifty-per-head{{?? it.progress>=30}}thirty-per-head{{??}}start-head{{?}}"></div>
                <div data-column="photo" class="group-photo-container"></div>
                <div data-column="description" class="group-item-description"></div>
                <div data-column="address_zip" class="group-item-description">〒</div>
                <div data-column="address" class="group-item-description"></div>
            </div>
        </div>`;
        const photoPresenter = '<img class="employee-photo" src={{=it.photo}} />';

        //データ定義
        const columns = [
            {
                id: 'title',
                name: 'title',
                dataField: 'title'
            }, {
                id: 'description',
                name: 'description',
                dataField: 'description'
            }, {
                id: 'photo',
                dataField: 'photo',
                presenter: photoPresenter
            }, {
                id: 'progress',
                dataField: 'progress'
            }, {
                id: 'address',
                name: 'address',
                dataField: 'address'
            }, {
                id: 'address_zip',
                name: 'address_zip',
                dataField: 'address_zip'
            }
        ];

        //(1)変数宣言
        let data = []; //商品管理データの雛形
        let dataView,TrellisGrouping;

        //(2)データの更新
        const dataPut = async (updateData) => {
            const params = new URLSearchParams();
            console.log(updateData);
            params.append('data', JSON.stringify(updateData));
            const res =  await axios.put('/update', params);
            data = res.data;
        }

        //(3)ドラッグアンドドロップ時に反応
        const dragDroppingFn = (sender,args) => {
            if(args.status !== 'beforeDropping') return; //ドロップ完了時のみ発火させる

            setTimeout(() => {
                //プログレスの値を更新
                for(let i = 0,len = workGroups.length; i < len; i++){
                    if(args.dataItem.work === workGroups[i]){
                        args.dataItem.progress = i * 20;
                    }
                }
                dataPut(sender.dataSource_);
            },500); //ディレイが無いとうまく動作しない模様
        };

        //(4)データと描画更新
        const renderer = async () => {
            const res = await axios.get('/getData');
            data = res.data;
            dataView = new GC.Spread.Views.DataView(document.getElementById('grid1'), data, columns, new GC.Spread.Views.Plugins.GridLayout({
                grouping: [{
                    field: 'work',
                    preDefinedGroups: workGroups,
                    header: {height: 24}
                }],
                rowTemplate: rowTemplate,
                rowHeight: 150,
                groupStrategy: TrellisGrouping
            }));
        };

        setInterval(renderer,3000); //3秒ごとにデータと描画を更新

        //(5)初期実行
        const init = async () => {
            TrellisGrouping = new GC.Spread.Views.Plugins.TrellisGrouping({panelUnitWidth: 190});
            renderer();

            //ドラッグ&ドロップ時のイベントを追加
            TrellisGrouping['dragDropping'].addHandler(dragDroppingFn);
        };
        init();
    </script>
</body>

</html>
(1)変数宣言

 まずは、使い回す変数を宣言します。

let data = []; //商品管理データの本体
let dataView,TrellisGrouping;
(2)データの更新メソッド

 データの更新があった際に/updateのAPIにアクセスする関数です。

        const dataPut = async (updateData) => {
            const params = new URLSearchParams();
            console.log(updateData);
            params.append('data', JSON.stringify(updateData));
            const res =  await axios.put('/update', params);
            data = res.data; //本体を更新 (B)
        }

 ブラウザの時にaxiosのPOSTの値が送信されない?そんなことはないの記事などにあるように、axiosでPOSTやPUTリクエストを送る際にはURLSearchParamsを利用すると想定通りにリクエストが投げられます。サーバー側では受け取ったデータをoriginDataに上書きして更新しています(A)。更新されたデータがレスポンスとして返って来るので、クライアント側でもデータの更新をします(B)。

server.js
省略

app.put('/update', (req, res) => {
    const updateData = JSON.parse(req.body.data);
    originData = updateData; //元データを更新 (A)
    console.log(originData);
    res.json(originData);
});

省略
(3)ドラッグアンドドロップしたときにデータ更新を行う

 先ほど紹介したドラッグアンドドロップの処理の内部で(2)で紹介したdataPut()を呼び出します。

        //(3)ドラッグアンドドロップ時に反応
        const dragDroppingFn = (sender,args) => {
            if(args.status !== 'beforeDropping') return; //ドロップ完了時のみ発火させる

            setTimeout(() => {
                //プログレスの値を更新
                for(let i = 0,len = workGroups.length; i < len; i++){
                    if(args.dataItem.work === workGroups[i]){
                        args.dataItem.progress = i * 20;
                    }
                }
                dataPut(sender.dataSource_);
            },500); //ディレイが無いとうまく動作しない模様
        };

 ドラッグアンドドロップのイベント取得の都合上、setTimeout()で僅かにディレイさせないとうまくデータが更新されません。

(4)データと描画更新

 ここからはデータが動的に変わっていくため、データの取得と描画は初期実行だけではないです。

 renderer()を作成し、その中でデータ取得と描画が行われるようにしましょう。

		//(4)データと描画更新
        const renderer = async () => {
            const res = await axios.get('/getData');
            data = res.data;
            dataView = new GC.Spread.Views.DataView(document.getElementById('grid1'), data, columns, new GC.Spread.Views.Plugins.GridLayout({
                grouping: [{
                    field: 'work',
                    preDefinedGroups: workGroups,
                    header: {height: 24}
                }],
                rowTemplate: rowTemplate,
                rowHeight: 150,
                groupStrategy: TrellisGrouping
            }));
        };

        setInterval(renderer,3000); //3秒ごとにデータと描画を更新

 またsetInterval(renderer,3000);で3秒ごとにデータ更新と再描画が行われるようになります。

(5)初期実行の処理

 いよいよ実行手前です。先ほどのrenderer()を内部で呼び出しつつ、最初の1回だけ呼び出せば良い処理をini()にまとめています。

        //(5)初期実行
        const init = async () => {
            TrellisGrouping = new GC.Spread.Views.Plugins.TrellisGrouping({panelUnitWidth: 190});
            renderer();

            //ドラッグ&ドロップ時のイベントを追加
            TrellisGrouping['dragDropping'].addHandler(dragDroppingFn);
        };
        init();

実行して確認

 これで内容を確認すると、初期状態にserver.jsで記述したサンプルデータが描画され、カードをドラッグアンドドロップで移動させると、状態が更新されサーバーサイドのoriginDataも更新されます。

 ページをリロードしても、そのままの状態が保持されるようになりました。

6. PayPalのデータと連動する

 では実際のデータと紐づけていきます。

 server.jsにPOSTリクエストを受け付けるAPIを作成します。

 PayPalは購入通知があるとWebhookを飛ばす仕組みがあります。これを利用して、PayPalから送られてきた購入者情報とトレリスUIを連動させましょう。

 PayPalのデータに合わせるためにoriginDataを少し改良します。

server.js
省略

let originData = [
    {
        "work": '未発送',
        "paypal_txn_id": '',
        "clickpost_id": '',
        "payer_email": '',
        "description": 'Nefry BT x 1',
        "title": `菅原のびすけ`,
        "photo": './images/ds.png',
        "progress": 0,
        "address": `東京都千代田区外神田2-9-3\nユニオンビル工新 8F`,
        "address_zip": '101-0021'
    }
]

省略

app.post('/ipn', async (req, res) => {
    const ipn = req.body; //PayPalのWebhook Body
    console.log(ipn)
    const addItem = {
        "work": "未発送",
        "paypal_txn_id": ipn.txn_id,
        "clickpost_id": '',
        "payer_email": ipn.payer_email,
        "description": ipn.item_name,
        "title": `${ipn.first_name} ${ipn.last_name}`,
        "photo": "./images/ds.png",
        "progress": 0,
        "address": `${ipn.address_state}${ipn.address_city}\n${ipn.address_street}`,
        "address_zip": ipn.address_zip
    };
    originData.push(addItem); //元データに追加
    res.end(JSON.stringify({message: 'Success!'}));
});

省略

 この状態で、IPN Simulator(PayPalの決済をシミュレーションするツール)を利用するとPayPalから購入者情報が送られて、トレリスに反映されます。

7. 残りの実装について

 Spread.Viewsの機能から離れるので軽く紹介します。

クリックポスト連携

 クリックポストはAPIが無いので連携が困難です。そこで、Headless Chromeでアクセスしにいき、中の操作を自動化して決済やラベル印字までを行います。

 ライブラリはpuppeteerを利用します。Puppeteerを利用した自動化は本筋ではないので全部の紹介せずに雰囲気だけ伝えます。

 まずはPuppeteerのインストールです。

npm i --save puppeteer

 読み込みとエミュレートするデバイスを指定します。Puppeteerはブラウザを起動してWebサイトの操作を自動制御していきますが、スマホビューから見ている状態をエミュレートできます。

 通常のPC向けのビューとスマホ向けのビューだと、スマホ向けの画面の方がシンプルな作りになっていることが多いので、僕が自動制御を行う際はiPhone 6などを指定して自動制御をしています。

server.js
'use strict';

const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone 6'];

省略

 また、/clickpostにPOSTリクエストがあった際にPuppeteerでの自動化処理を記述します。

server.js
省略

app.post('/clickpost', async (req, res) => {
	 /*
	 ここにPuppeteerでの自動化処理を記述
	 */
    res.end(JSON.stringify({message: 'Success!'}));
});

省略

 フロント側ではカードがラベル申込/決済_済の状態になったときに/clickpostのパスにPOSTリクエストが送られるようにし、カードがラベル申込/決済_済になるとクリックポストへの自動アクセスと決済処理が行われます。

index.html
省略
<script>
省略
        const dragDroppingFn = (sender,args) => {
            if(args.status !== 'beforeDropping') return;
            setTimeout(() => {
                for(let i = 0,len = workGroups.length; i < len; i++){
                    if(args.dataItem.work === workGroups[i]){
                        args.dataItem.progress = i * 20;
                    }
                }

                //今回追加箇所
                if(args.dataItem.work === 'ラベル申込/決済_済'){
                    alert('クリックポストでラベルを発注します。');

                    //args.dataItemのデータをそのまま送る
                    const params = new URLSearchParams();
                    params.append('data', JSON.stringify(args.dataItem));
                    const res =  await axios.put('/clickpost', params);
                }

                dataPut(sender.dataSource_);
            },500);
        };

省略
</script>
省略

 実行してみるとこんな感じです。

 クリックポストのログイン部分を自動化する部分のコードになります。クリックポストはYahooのログインシステムを利用しているため、Yahooのログインを自動化する処理とも言えます。

server.js
省略
const browser = await puppeteer.launch({headless: false});
const page = await browser.newPage();
await page.goto('https://clickpost.jp/mypage/index'); //ページに移動
await page.click('.logInBtn'); //ログインボタンを押す
console.log(`ログインボタン done`);

await page.waitFor('#username');
await page.type('#username','ユーザー名');   //ユーザー名の入力
console.log(`ユーザー名入力 done`);

await page.click('#btnNext'); //次へボタンを押す
await page.waitFor('#idWrap.dispNone');
省略

 コメントに処理の流れを書いていますが、かなり直感的です。

 実際にこの箇所だけを動かすとこんな感じです。

 自動化できるとかなりの感動があります。

Gmail APIを使ってメール送信の自動化

 購入者に対して、発送完了連絡を自動化します。

 こちらもクリックポスト同様にPOSTリクエストを受け付けるAPIを作成します。/sengmailとしました。

server.js
省略

app.post('/sendmail', async (req, res) => {
	 /*
	 ここにPuppeteerでの自動化処理を記述
	 */
    res.end(JSON.stringify({message: 'Success!'}));
});

省略
index.html
省略
<script>
省略
        const dragDroppingFn = (sender,args) => {
            if(args.status !== 'beforeDropping') return;
            setTimeout(() => {
                for(let i = 0,len = workGroups.length; i < len; i++){
                    if(args.dataItem.work === workGroups[i]){
                        args.dataItem.progress = i * 20;
                    }
                }

                if(args.dataItem.work === 'ラベル申込/決済_済'){
                    alert('クリックポストでラベルを発注します。');

                    //args.dataItemのデータをそのまま送る
                    const params = new URLSearchParams();
                    params.append('data', JSON.stringify(args.dataItem));
                    const res =  await axios.put('/clickpost', params);
                }

                //今回追加箇所
                if(args.dataItem.work === '発送完了'){
                    alert('発送完了メールを送ります。');

                    //args.dataItemのデータをそのまま送る
                    const params = new URLSearchParams();
                    params.append('data', JSON.stringify(args.dataItem));
                    const res =  await axios.put('/sendmail', params);
                }

                dataPut(sender.dataSource_);
            },500);
        };

省略
</script>
省略

 GmailをNode.jsから送信する方法はGoogle公式ライブラリを利用してNode.jsからGmailの送受信をしてみようの記事にまとめたので、こちらを参照してください。

 組み込むと、こんな形で発送完了にカード移動をすると裏側でメールが送られます。

まとめ

 トレリス風なUIもSpread.Viewsを使うことで簡単に扱うことができました。

 今回は、ドラッグアンドドロップのイベント取得をやりましたが、Spread.Viewsではクリックしたときや編集したときなどのイベントを取得できるので、イベントを活用してトリガーにすることで他のAPIとの連携部分も作っていけそうです。

 APIなどの連携を図れば、商品管理に留まらず、プロジェクト管理やスクラムなど活用ジャンルが無限に広がります。イベントがライブラリ側にしっかりと実装されていてAPIなどの連携がしやすいのもSpread.Viewsのメリットだと思います。皆さんも是非使ってみてはいかがでしょうか。

 IoT技術の成長により、データの扱い方や扱うデータの種類も広がっています。2~4回でそれぞれ違った指向のアプリケーションを作成しましたが、Spread.Viewsが幅広い用途に対応できるライブラリであることが今回の連載を通して伝われば幸いです。

 それではまたどこかでお会いしましょう!

  • LINEで送る
  • このエントリーをはてなブックマークに追加

著者プロフィール

  • 菅原のびすけ(dotstudio株式会社)(スガワラ ノビスケ)

     日本最大規模のIoTコミュニティ「IoTLT」主催。岩手県立大学大学院ソフトウェア情報学研究科を卒業後、株式会社LIGでWebエンジニアとして入社し、Web開発に携わる。2016年にdotstudio株式会社を立ち上げ、今はIoT領域を中心に活動している。JavaScript Roboticsコミ...

All contents copyright © 2005-2018 Shoeisha Co., Ltd. All rights reserved. ver.1.5