STORES.jp の倉庫サービスを使ってみて

出店したと少し前のエントリーで書いた STORES.jp ですが、BASE と同じく、CtoC のサービスとしてとても注目されていて、両者とも急成長しているのは皆さんご存知の通りだと思います。

スピード感があって、次々と新サービスが打ち出されてくるものの、まだまだ発展途上のサービスであり、使い心地としては、箱庭の中に収まっている間はシンプルで心地いいんですが、BtoC、あるいは、BtoB と規模が大きくなり、顧客のニーズが多様化するに連れ、非常に使いづらいか、あるいは、使いものにならなくなっていく、というのが率直な感です。基本的にビギナーが簡単に参入できることにフォーカスしすぎているため、極端にシンプルにしすぎているためでしょう。

STORES.jp を利用する最大のメリット(BASE は使ってないのですが、多分同じだと思います)は、やはり倉庫サービスと言えます。オンラインストアのトラブルの大部分は、品物の発送に起因するものだそうですが、倉庫サービスを利用すると、在庫の管理・発送という業務から開放され、本来注力すべき仕入れや販売に専念できるからです。

しかしながら、メリットであると同時に、まだ立ち上がったばかりで、問題点も多く抱えているように感じます。
その中でも、クリティカルな問題点について、整理をしてみたいと思います。

その問題点とは、ズバリ「預け入れの手順がシンプル過ぎて、その後が大変」ということです。

そもそも論として、STORES.jp には、Amazon でいうところの asin (Amazon Standard Item Number) のような仕組みがありませんし、各テナントストアは完全に独立しています。
なので、横断的に商品そのものを一意に管理するための仕組みそのものが存在しません。
じゃあどうなっているのかというと、あくまでも推測ですが、アイテムを登録した際に、24 文字の一意な id が発行されるようで、便宜上これを item number と呼ぶことにしますが、これでストアの側では受注などを管理をします。
しかし、item number を少なくとも利用者(ストアのオーナー)が意識し、利用することはできません。

次に、じゃあ倉庫どうやって在庫を管理しているのかというと、預け入れた際に、20 文字の id ができるようなのです。
これは便宜上 storage number と呼ぶことにしましょう。
ただ、そのままだと item number と storage number は全く無関係なので、意味がわからなくなりますよね?
どうやって判定するのか、というと、倉庫でアイテムの写真を取って、それで識別してね、というアナログな仕組みなんです。
実際の画面をお見せしましょう。
商品の低解像度の写真と、在庫数だけしか表示されません。
ちなみに、左上から、預け入れが古い順となっていて(ただし、バグがあり位置が時々入れ替わる)、それにより先入れ先出しを担保します。

Screen Shot 2014-08-22 at 1.51.30 PM

非常にシンプルで設計思想自体はとてもいいと思うのですが、でも、ちょっとこれはシンプルすぎる。

まず、在庫の管理ができません。
なぜか、といえば、storage number と item number とが紐付いていないので、item number が同じ商品がいくつ存在するのか、それを調べることができないからです。(無論、人間が数えれば済みますけど)
仮に紐付けたとしても、アイテムを削除して、登録しなおせば、storage number に紐付いた item number がなくなることもありえます。

一応、商品の名称も登録されているようなので、発送を申請した時に送られてくるメールなどに表示されるようになっているのですが、発送の申請時、並びに申請後のウェブサイトでは確認することができません。
しかも、この名称も、倉庫で登録作業をする人が手作業で入力しているようで、時々間違えて登録されているので、更に混乱に拍車がかかる状態が生じるわけです。

よって、この問題を解決するもっとも手っ取り早い方法というのは、倉庫に預け入れてある商品について、item number と storage number を紐付けてあげることです。

ただし、これには注意点があります。

ひとつは、バリエーションの有る商品、例えば、サイズ違い、色違い、これらの扱いをどうするのか、という問題点。別々のアイテムとして登録すれば問題はないのですが、画像はアイテムごとにしか登録できないので、画像で識別する方法だと問題が生じる予感がします。(現状、深くは調査していません)

それと、もうひとつは、同じ商品であるにもかかわらず、複数アイテム登録せざるを得ない現状がある、それに起因して生じている問題です。具体的にいえば、STORES.jp はシンプル過ぎて融通がきかないので、まとめ買いすると割引、というような機能がなく、セット商品を別のアイテムとして登録せざるを得ないわけです。セット商品といっても、ただ単にまとめ売りでしかないので、倉庫から出す数が違うだけで、アイテム的には同じものなんですが、item number が複数ある、そして在庫状況によっては、複数の strage number の商品をパッキングしないといけない、という状態が発生しうるわけです。

よって、下手に作りこまれない事自体が、今回は不幸中の幸いとなっているわけです。

以上のように、悪戦苦闘しているわけですが、STORES.jp の事務局から、数日前に、新倉庫サービスの案内を頂いたのですが、すぐに始まるサービスではないようだし、当面は、Chrome Extension を制作することにより、この件に関する負荷を軽減していこうと思います。

で、振りが長かったですが、ここからが本題です。

STORES.jp ですが、Web Inspector でみると、よくわからないタグがあり、明らかに何らかの Framework で動的にページが生成されていることに気づきます。
なんだろうかと思ったら、AngularJS という Google による Framework を採用しているようです。

ブラ三以来ですから、かれこれ 5 年くらいは JavaScript は触ってこなくて忘れているところですが、当時は、Firefox + GreaseMonkey で、Chrome はでたばっかりくらいでしたが、今は Chrome に絞って、AngularJS を使った Extension を書いたほうがいいように思います。

とはいえ、リハビリが必要ですから、まずは、スクリーンショットの画面で、storage number をキーにメモを取れるように(localStrage を使い永続させる)する Chrome Extension を作成し、これは、GraseMonkey と共用とし、その後、AngularJS を学習して、より作りこんでいく、という方向で行きたいと思います。
ちょっと 課題 もあるようなので。

STORES.jp のサービス自体のスピードが早いので、拙速にいかないと、完成した頃には、動かなくなっていそうですから。

というわけで、貼り。
やっつけ仕事ですから、動かなかったら、ごめんなさい。予め謝っておきます。

やはり動的に生成しているページのため、普通に GreaseMonkey Script を実行すると、その時点では在庫一覧が取得されていないため、textarea が表示されませんでした。ちょっと不細工ですが、setInterval を使って対処しました。

とりあえず、動作が不安定なので、AngularJS が動的に element を生成して DOM に追加されたタイミングで event listener で hook するようにしないと安定して動作しなさそうです。jQuery の live() で img の click を仕込んでみたんですが、ちょっと気持ち悪い動きになったのと、jQuery 自体の使い方をすっかり忘れているので、今日はもう寝ます。w

// ==UserScript==
// @name            STORES.jp misc utilities
// @namespace       org.shigematsu.STORES_utils
// @include         https://stores.jp/dashboard*
// @version         $Revision$
// ==/UserScript==

// $Id$

(function(){
    console.log('Hello, world!');

     // GM functions for Chrome
     // @copyright      2009, James Campos
     // @license        cc-by-3.0; http://creativecommons.org/licenses/by/3.0/
    if ((typeof GM_getValue == 'undefined') || (GM_getValue('a', 'b') == undefined)) {
        GM_addStyle = function(css) {
            var style = document.createElement('style');
            style.textContent = css;
            document.getElementsByTagName('head')[0].appendChild(style);
        };

        GM_deleteValue = function(name) {
            localStorage.removeItem(name);
        };

        GM_getValue = function(name, defaultValue) {
            var value = localStorage.getItem(name);
            if (!value)
                return defaultValue;
            var type = value[0];
            value = value.substring(1);
            switch (type) {
                case 'b':
                    return value == 'true';
                case 'n':
                    return Number(value);
                default:
                    return value;
            }
        };

        GM_log = function(message) {
            console.log(message);
        };

        GM_registerMenuCommand = function(name, funk) {
            //todo
        };

        GM_setValue = function(name, value) {
            value = (typeof value)[0] + value;
            localStorage.setItem(name, value);
        };
    }

    // STORAGE SERVICE
    console.log(document.URL);
    if ('https://stores.jp/dashboard#!/storages' == document.URL) {
        t = window.setInterval(function(){
            var items = document.querySelectorAll('ul#storage_wrapper>li');
            if (!items.length) return;
            for (var i = 0; i < items.length; ++i) {
                try {
                    function stateChanged(ev) {
                        GM_setValue(ev.target.name, ev.target.value);
                    };
                    var img  = items[i].querySelector('img');
                    var sn   = img.src.replace(/^.*\/([0-9a-f]{20})_100x100\.jpeg$/,'$1');
                    var name = 'sn:'+sn;
                    var note = GM_getValue(name, sn); // sn, or storage number
                    var ta   = document.createElement('textarea');
                    ta.name  = name;
                    ta.value = note;
                    ta.addEventListener('input', stateChanged, false);
                    ta.addEventListener('text',  stateChanged, false);
                    img.parentNode.appendChild(ta);
                }
                catch (e) { GM_log(e); }
            }
            window.clearTimeout(t);
        }, 200);
    }
})();

/* vim:set foldmethod=marker: */