Amazonパンチアウトを利用する購買アプリケーション

## 概要

Eコマースアプリケーションを開発するための標準にcXMLがあります。その中にパンチアウトという仕組みがあり、AribaやAmazonなどがその機能を使ってオンラインカタログと注文の仕組みを提供しています。

この投稿ではパンチアウトを利用して物品を購買するアプリケーションの例を紹介します。ここではパンチアウト機能によってカタログや注文の仕組みを提供するサーバーをパンチアウトサイトと呼びます。

購買アプリケーションはパンチアウトサイトに対してPunchOutSetupRequestをPOSTします。リクエストを受け取ったパンチアウトサイトはPunchOutSetRequestに設定されたBuyerCookieを使って、ユーザー毎のカタログとショッピングカートを準備し、そのカタログにアクセスするURLをPunchOutSetupResponseに設定して返信します。

BuyerCookieは各購買アプリケーションがパンチアウトセッション毎に一意になるように生成する文字列です。UUIDなどを用いることが多いです。

購買アプリケーションはフレームかウィンドウを開いて、その中でPunchOutSetupResponseに設定されたURLにアクセスしてパンチアウトサイトが準備したカタログを表示し、ユーザーはその中で購入物品をショッピングカートに追加します。

ユーザーがパンチアウトサイト上でショッピングカートをチェックアウト（購入物品の確定）を行うと、パンチアウトサイトは購買アプリケーションにショッピングカートの内容を入れたPunchOutOrderMessageをPOSTします。

PunchOutOrderMessageを受け取った購買アプリケーションはPunchOutOrderMessageからショッピングカートの中身を自アプリケーションの購入品目リストに転記してパンチアウトサイトの画面を閉じます。一般的な購買アプリケーションでは、この後、購入品目リストを転記した購入申請書類をワークフローシステムなどに送り、決裁が行われた後にAmazonにOrderRequestを送信して発注処理が行われます。

##### オンラインカタログとしてパンチアウトを利用するシーケンス

![buyer_catalog-server.png](https://qiita-image-store.s3.amazonaws.com/0/87984/4eed8072-e2dd-f37c-9fc5-48aecc8398d2.png)

## 購買アプリケーションを実行した様子

##### パンチアウトサイトからPunchOutSetupResponseを受信してカタログ画面を表示した様子

![buyer-01.png](https://qiita-image-store.s3.amazonaws.com/0/87984/7f556c9e-902e-e99d-123a-43fc1905570e.png)

##### パンチアウトサイト上でショッピングカートに購入物品を追加した様子

![buyer-02.png](https://qiita-image-store.s3.amazonaws.com/0/87984/1a9fbc89-8554-1c20-f36d-7fda55da3ac9.png)

##### パンチアウトサイト上でチェックアウト後、PunchOutOrderMessageを受け取って自アプリケーションに購入物品を取り込んだ様子

![buyer-03.png](https://qiita-image-store.s3.amazonaws.com/0/87984/67c23923-2f84-0d2d-0468-97777910f841.png)

##### アプリケーションを動かしている様子の動画 (YouTube)

https://www.youtube.com/watch?v=v7nwgebzbXQ

## アプリケーションの構成

Amazonはビジネスアカウント向けにこのパンチアウトの仕組みを提供していますが、ビジネスアカウントを取得せずに試作したかったので、PunchOutSetupRequest/PunchOutSetupResponseとPunchOutOrderMessageを処理するカタログサーバーを作って実験しました。

なるべく簡単に実験ができるように、購買アプリケーションとカタログサーバーは、MEANスタックの上で開発・実行しました。

## 肝の部分のコード

##### Catalog-ServerのPunchOutSetupRequestを受けてPunchOutSetupResponseを返信する処理

```JavaScript
/**
 * Expressのルーター
 * PunchOutSetupRequestのPOSTデータを受け取ってPunchOutSetupResponseを返信する
 **/
router.post('/setup', (req, res) => {
    var data = req.body; 

    const punchoutRequest = punchoutSetupRequestFromData(data);

    console.log('Got PunchOutSetupRequest:');
    console.log(JSON.stringify(punchoutRequest));

    punchoutSetupResponse(punchoutRequest, res);
});

/**
 * PunchOutSetupRequestをパースしてJSON形式に変換する
 **/
function punchoutSetupRequestFromData(data) {
    const root = ...;
        /* cXMLのルートエレメントをdataから取得 */
    const header = ...;
        /* cXMLのHeaderを取得 */
    const fromIdentity = ...;処理
        /* HeaderからFrom.Credential.Identityを取得 */
    const toIdentity = ...;
        /* HeaderからTo.Credential.Identityを取得*/
    const senderIdentity = ...;
        /* HeaderからSender.Credential.Identityを取得*/;
    const senderSharedSecret = ...;
        /* Sender.Credential.IdentityのSharedSecret　（アプリケーションの認証に使われる）;
    const userAgent = ...;
        /* HeaderからSender.UserAgentを取得 */;
    const punchout = ...;
        /* PunchOutSetupRequestの本体 */;
    const cookie = ...;
        /* PunchOutSetupRequestからBuyerCookieを取得 （パンチアウトセッションのIDになる）*/
    const formPostURL = ...;
        /* PunchOutOrderMessageをPOSTするURL */;

    var poRequest = {
        payloadId: root.attributes.payloadID,
        timestamp: root.attributes.timestamp,
        from: fromIdentity.elements[0].text,
        to: toIdentity.elements[0].text,
        sender: senderIdentity.elements[0].text,
        sharedSecret: senderSharedSecret.elements[0].text,
        userAgent: userAgent.elements[0].text,
        operation: punchout.attributes.operation,
        buyerCookie: cookie.elements[0].text,
        browserFormPost: formPostURL.elements[0].text
    };

    return poRequest;
}

/**
 * PunchOutSetupResponseを返信する関数
 **/
function punchoutSetupResponse(punchoutRequest, res) {
    /*
        データベースにこのパンチアウトセッション用のショッピングカートを作成する。
 
        punchoutRequest.buyerCookieをカートのキーとして使用する。

        punchoutRequest.operationには新規作成や編集などのモードが設定されているので、
        実際のシステムではこれに応じた振る舞いをする。

        punchoutRequest.browserFormPostには購買アプリケーション側の
        PunchOutOrderMessage POSTの受け口のURLが入っている。
    */
    /*
        CatalogServiceはMongoDBにショッピングカートを作成する処理を行う。
    */
    CatalogService.prepareShoppingCart(punchoutRequest.buyerCookie, punchoutRequest.operation, punchoutRequest.browserFormPost).then( (result) => {
        /*
            カタログページのURLを生成
        */
        const url = 'http://localhost:5500/catalog/' + punchoutRequest.buyerCookie;

        const timestamp = moment().format();
        /*
            PunchOutSetupResponseのテンプレートを読み込み、timestampと
            StartPage.URLを差し替え、その結果を購買アプリケーションに返信する。
        */
        fs.readFile('routes/resources/PunchoutSetupResponse.xml', 'utf8', (err, data) => {
            if(err) {
                res.send(err);
            } else {
                let responseXml = data.replace('{timestamp}', timestamp).replace('{url}', url);
                console.log(responseXml);
                res.header('Content-Type', 'text/xml');
                res.header('Cache-Control', 'private, no-cache, no-store, must-revalidate');
                res.header('Pragma', 'no-cache');
                res.send(responseXml);
            }
        });
    }).catch ( (err) => {
        console.log(err);
        res.send(err);        
    });
}

```

##### 購買アプリケーション側でPunchOutOrderMessageを受け取った後の処理

```JavaScript

function _get_element(data, elementName) {
    return data.elements.find(e => e.name === elementName);
}

function _get_elements(data, elementName) {
    return data.elements.filter(e => e.name === elementName);
}

const PunchoutHandler = {

    /* 
        PunchOutOrderMessageをMongoDBに保存する。
    */
    save: (json) => {
        let content = {};

        const cxml = _get_element(json, 'cXML');
        content.payloadID = cxml.attributes.payloadID;
        content.timestamp = cxml.attributes.timestamp;

        const header = _get_element(cxml, 'Header');
        content.from = _get_element(_get_element(_get_element(header, 'From'), 'Credential'), 'Identity').elements[0].text;
        content.to = _get_element(_get_element(_get_element(header, 'To'), 'Credential'), 'Identity').elements[0].text;
        content.sender = _get_element(_get_element(_get_element(header, 'Sender'), 'Credential'), 'Identity').elements[0].text;
        content.userAgent = _get_element(_get_element(header, 'Sender'), 'UserAgent').elements[0].text;

        const message = _get_element(_get_element(cxml, 'Message'), 'PunchOutOrderMessage');
        content.buyerCookie = _get_element(message, 'BuyerCookie').elements[0].text;

        const messageHeader = _get_element(message, 'PunchOutOrderMessageHeader');
        content.currency = _get_element(_get_element(messageHeader, 'Total'), 'Money').attributes.currency;
        content.total = parseInt(_get_element(_get_element(messageHeader, 'Total'), 'Money').elements[0].text);

        /*
            パンチアウトサイトでショッピングカートに入れた物品
        */
        content.itemIns = [];
        const itemElements = _get_elements(message, 'ItemIn');
        itemElements.forEach(elem => {
            let item = {};
            item.quantity = parseInt(elem.attributes.quantity);
            item.supplierPartID = _get_element(_get_element(elem, 'ItemID'), 'SupplierPartID').elements[0].text;
            const detail = _get_element(elem, 'ItemDetail');
            item.currency = _get_element(_get_element(detail, 'UnitPrice'), 'Money').attributes.currency;
            item.unitPrice = parseInt(_get_element(_get_element(detail, 'UnitPrice'), 'Money').elements[0].text);
            item.description = _get_element(detail, 'Description').elements[0].text;
            item.unitOfMeasure = _get_element(detail, 'UnitOfMeasure').elements[0].text;
            item.classificationDomain = _get_element(detail, 'Classification').attributes.domain;
            item.classification = _get_element(detail, 'Classification').elements[0].text;
            item.manufacturerPartID = _get_element(detail, 'ManufacturerPartID').elements[0].text;
            item.manufacturerName = _get_element(detail, 'ManufacturerName').elements[0].text;
            content.itemIns.push(item);
        });
        content.updated = true;

        return new Promise((resolve, reject) => {
            /*
                MongoDBに保存。
            */
            MongoClient.connect(config.db.url, config.db.options, (err, client) => {
                if (err) {
                    reject(err);
                }
                const dbo = client.db('buyer');
                dbo.collection('punchout_contents').findOneAndDelete({ buyerCookie: content.buyerCookie }, (err, result) => {
                    dbo.collection('punchout_contents').insertOne(content, (err) => {
                        if (err) {
                            client.close();
                            reject(err);
                        } else {
                            console.log('Punchout Response saved.');
                            client.close();
                            resolve(content);
                        }
                    });
                });
            });
        });
    },

   /*
       購買画面に表示する際にデータベースに保存済のショッピングカートの中身を取り出す処理。
   */
   consume: (buyerCookie) => {
        return new Promise((resolve, reject) => {
            MongoClient.connect(config.db.url, config.db.options, (err, client) => {
                if (err) {
                    reject(err);
                }
                const dbo = client.db('buyer');
                dbo.collection('punchout_contents').find({ buyerCookie: buyerCookie }).toArray( (err, result) => {
                    if(err) {
                        client.close();
                        reject(err);
                    } else {
                        dbo.collection('punchout_contents').findOneAndUpdate({buyerCookie: buyerCookie}, {$set: {updated: false}}, (err, r) => {
                            client.close();
                            if(err) {
                                console.log(err);
                            }
                            resolve(result[0]);
                        });
                    }
                });
            });
        });
    },

    ....
    ....
};

module.exports = PunchoutHandler;
```

## 雑感

多くのネット通販サイトがこのパンチアウトに対応してくれれば、中継サーバーや購買アプリケーションを開発して安価なサービスを提供できそうです。クラウド型の会計ソフトと連携するなどすれば中小企業の仕入れや販売に関する情報管理や業務を効率化できるように思います。元々cXMLはAribaなどの大企業相手のB2Bの仕組みを標準化するためのものでしたが、企業の大小を問わずに有効な仕組みではないかと思います。自営業者の一人として、経理処理や販売管理の面倒くささは切実な悩みです。同じ悩みを共有している方も多いと思います。Amazonや楽天などのネット販売サービスに登録している事業者さんたちや会計ソフトを提供している企業のみなさんには、こういうシステムへの関心を持っていただけたら嬉しいですし、何らかの形で日本の中小企業を応援できればと思っています。

最近、このようなアプリケーションの開発やドキュメント作成にはVisual Studio Codeを使っています。大変快適に作業ができる素晴らしいソフトウェアだと思います。

## 参考資料

- [Amazon Punchout FAQ](https://www.amazon.com/gp/help/customer/display.html?ie=UTF8&nodeId=201738140) 
- [cXML.org](http://cxml.org/)　PunchOutSetup/PunchOutOrderなどのcXML文書のフォーマットはここで知ることができます。

> このような記事を[ブログ](https://tsubakicraft.wordpress.com)に投稿していますので、お時間のある方は覗いてみてください。
>（これが[本業](http://tsubakicraft.jp)ではありません）