kintone
投稿日:

「楽天とAmazonと自社サイト、在庫がバラバラで困っていませんか?」── kintoneで複数モールの在庫を一元管理する

皆さんこんにちは。kintoneアプリエンジニアのtomiokaです。

先日、アパレルEC事業者のお客様から「在庫数が合わなくて、欠品と過剰在庫を同時に抱えている」と相談がありました。

楽天市場、Amazon、自社ECサイトの3チャネルで販売しているのですが、在庫管理はチャネルごとに別々のExcelで行っている状態。スタッフ5名で約800SKUを扱っており、売れた商品の在庫反映が追いつかず、1つのチャネルで売り切れたのに別のチャネルでは在庫ありのまま注文が入ってしまう、、、、というトラブルが月に3〜5件発生していたそうです。

今回は、kintoneで複数モールの在庫を一元管理し、在庫変動を即座に反映できる仕組みを構築した事例を紹介します。2〜3チャネルで販売しているEC事業者で、SKU数500〜2000程度、在庫管理をExcelや各モールの管理画面で個別に行っている担当者の方に参考になる内容です。

チャネルごとのExcel管理で起きていた問題

複数モールで在庫をExcelで管理していると、以下の問題が起きます。

  • チャネルAで売れた分をチャネルBのExcelに反映し忘れ、売り越し(在庫切れなのに注文を受ける)が発生する
  • 入荷時に各チャネルのExcelすべてに在庫数を追記する必要があり、転記ミスが起きる
  • 「実在庫」と「各チャネルの表示在庫」がズレたまま放置され、棚卸しで差異が大量に出る
  • 季節商品の在庫配分(どのチャネルに何個割り振るか)を勘で決めているため、機会損失が発生する
  • 在庫の動きがリアルタイムで見えないため、仕入れ判断が遅れる

特に「売り越し」は深刻でした。Amazonではショップ評価に直結しますし、楽天でもペナルティの対象になります。キャンセル・返金対応で顧客の信頼を失うだけでなく、モール側からの評価も下がるという二重の損失なんですよね。

構築した3アプリの構成

今回は以下の3つのアプリで構築しました。

アプリ名役割主なフィールド
商品マスタアプリSKU単位の商品情報・実在庫を管理SKUコード、商品名、カテゴリ、仕入先、実在庫数、安全在庫数
チャネル在庫アプリチャネルごとの割当在庫・販売数を管理SKU(ルックアップ)、チャネル名、割当在庫数、販売済み数、残在庫数
入出庫記録アプリ入荷・出荷・チャネル間移動を記録SKU(ルックアップ)、入出庫種別、数量、チャネル、日時、担当者

ポイントは、「実在庫」を商品マスタで一元管理し、各チャネルへの「割当在庫」をチャネル在庫アプリで管理する二層構造にしたことです。入出庫記録が登録されると、商品マスタの実在庫とチャネル在庫アプリの割当在庫が自動で更新されます。

商品マスタアプリのフィールド構成

フィールド名フィールドタイプフィールドコード備考
SKUコード文字列(1行)skuCode必須・ユニーク
商品名文字列(1行)productName
カテゴリドロップダウンcategoryトップス、ボトムス、アウター、小物
仕入先文字列(1行)supplier
仕入単価数値costPrice
実在庫数数値actualStock全チャネル合計の物理在庫
安全在庫数数値safetyStockこの数を下回ったらアラート
在庫ステータス文字列(1行)stockStatus自動判定(正常/注意/危険)
最終入荷日日付lastArrivalDate

チャネル在庫アプリのフィールド構成

フィールド名フィールドタイプフィールドコード備考
SKUコードルックアップlookupSku商品マスタアプリから取得
商品名文字列(1行)productNameCopyルックアップでコピー
チャネルドロップダウンchannel楽天市場、Amazon、自社EC
割当在庫数数値allocatedStockこのチャネルに割り当てた在庫
販売済み数数値soldCount
残在庫数計算remainingStock割当在庫数 – 販売済み数
在庫切れフラグ文字列(1行)outOfStockFlag自動判定

入出庫記録による在庫自動更新カスタマイズ

入出庫記録アプリにレコードが登録されたタイミングで、商品マスタの実在庫数を自動更新します。

(function() {
  'use strict';

  var PRODUCT_APP_ID = 101;
  var CHANNEL_APP_ID = 102;

  kintone.events.on(
    'app.record.create.submit.success',
    function(event) {
      var record = event.record;
      var skuCode = record.skuCode.value;
      var ioType = record.ioType.value;
      var quantity = parseInt(record.quantity.value, 10);
      var channel = record.channel.value;

      if (!skuCode || !quantity || isNaN(quantity)) {
        return event;
      }

      var productQuery = 'skuCode = "' + skuCode + '"';

      return kintone.api(
        kintone.api.url('/k/v1/records.json', true),
        'GET',
        { app: PRODUCT_APP_ID, query: productQuery }
      ).then(function(prodResp) {
        if (prodResp.records.length === 0) {
          console.error('SKU not found: ' + skuCode);
          return event;
        }

        var productRecord = prodResp.records[0];
        var currentStock = parseInt(
          productRecord.actualStock.value, 10
        ) || 0;
        var safetyStock = parseInt(
          productRecord.safetyStock.value, 10
        ) || 0;
        var productId = productRecord.$id.value;

        var newStock = currentStock;
        if (ioType === '入荷') {
          newStock = currentStock + quantity;
        } else if (ioType === '出荷'
                   || ioType === '販売') {
          newStock = currentStock - quantity;
        } else if (ioType === 'チャネル間移動') {
          return updateChannelStock(
            skuCode, channel, ioType, quantity
          ).then(function() { return event; });
        }

        var stockStatus = '正常';
        if (newStock <= 0) {
          stockStatus = '危険';
        } else if (newStock <= safetyStock) {
          stockStatus = '注意';
        }

        var updateBody = {
          app: PRODUCT_APP_ID,
          id: productId,
          record: {
            actualStock: { value: newStock },
            stockStatus: { value: stockStatus }
          }
        };

        return kintone.api(
          kintone.api.url('/k/v1/record.json', true),
          'PUT', updateBody
        ).then(function() {
          if (channel) {
            return updateChannelStock(
              skuCode, channel, ioType, quantity
            );
          }
        }).then(function() {
          return event;
        });
      });
    }
  );

  function updateChannelStock(
    skuCode, channel, ioType, quantity
  ) {
    var query = 'lookupSku = "' + skuCode + '"'
      + ' and channel = "' + channel + '"';

    return kintone.api(
      kintone.api.url('/k/v1/records.json', true),
      'GET',
      { app: CHANNEL_APP_ID, query: query }
    ).then(function(chResp) {
      if (chResp.records.length === 0) return;

      var chRecord = chResp.records[0];
      var chId = chRecord.$id.value;
      var allocated = parseInt(
        chRecord.allocatedStock.value, 10
      ) || 0;
      var sold = parseInt(
        chRecord.soldCount.value, 10
      ) || 0;

      var updateData = {};
      if (ioType === '販売') {
        updateData.soldCount = {
          value: sold + quantity
        };
      } else if (ioType === '入荷') {
        updateData.allocatedStock = {
          value: allocated + quantity
        };
      }

      if (Object.keys(updateData).length === 0) return;

      return kintone.api(
        kintone.api.url('/k/v1/record.json', true),
        'PUT',
        {
          app: CHANNEL_APP_ID,
          id: chId,
          record: updateData
        }
      );
    });
  }
})();

処理のポイントを整理します。

  • 入出庫種別(入荷/出荷/販売/チャネル間移動)に応じて、実在庫の加減算を切り替えています
  • 「チャネル間移動」の場合、実在庫は変動しません。例えば楽天からAmazonへ在庫を振り替える場合、物理在庫は変わらないためです
  • 実在庫が安全在庫数を下回った場合、ステータスを「注意」に自動変更します。一覧画面でフィルタリングすれば、発注が必要な商品をすぐに確認できます

ハマりどころ

実装中にいくつかハマったポイントがあります。

同時更新による在庫数の不整合

複数のスタッフが同時に入出庫記録を登録すると、GETで取得した在庫数が古い状態でPUTしてしまい、、、、在庫数がズレる問題が発生しました。kintoneのレコード更新APIではrevisionパラメータでリビジョンチェックができます。リビジョン不一致の場合はリトライする処理を入れることで、同時更新による不整合を防いでいます。

計算フィールドのAPIでの扱い

チャネル在庫アプリの「残在庫数」は計算フィールド(割当在庫数 – 販売済み数)です。計算フィールドはAPIでのPUT対象にできないため、、、、更新対象から除外する必要があります。実装初期に計算フィールドも含めてPUTリクエストを送り、エラーが返ってくる問題に遭遇しました。

マイナス在庫の取り扱い

入力ミスや登録順序の前後で、実在庫がマイナスになるケースがあります。最初はマイナスを許容しない設計にしていましたが、、、、実運用では「先に出荷登録してから入荷登録する」パターンがあり、一時的にマイナスを許容する必要がありました。ただし日次でマイナス在庫を検知するレポートは別途用意しています。

導入結果

導入から3か月後、以下の成果が出ました。

  • 売り越し(在庫切れなのに注文受付)の発生が月3〜5件からゼロになった
  • 在庫反映にかかる時間が1日遅れからリアルタイム(登録直後)に改善された
  • 棚卸し時の在庫差異が平均87件から12件に減少した
  • 安全在庫アラートにより、欠品による機会損失が月あたり約15万円分削減された
  • 在庫配分の判断が「勘」から「データ」に変わり、チャネルごとの回転率が可視化された

特にAmazonのショップ評価への影響が大きかったですね。売り越しによるキャンセルが無くなったことで、出荷遅延率が改善され、カート取得率の向上にもつながったと聞いています。

まとめ

今回はEC事業者の複数モール在庫をkintoneで一元管理した事例を紹介しました。

複数チャネルでの在庫管理は、チャネル数が増えるほど管理工数が指数的に増加します。Excelでの個別管理では、反映遅れや転記ミスによる売り越しを完全に防ぐことは困難です。

今回の構成で押さえておきたいポイントは以下の3つです。

  • 「実在庫」と「チャネル割当在庫」を二層構造で管理し、実在庫を一元管理する
  • 入出庫記録をトリガーに在庫数を自動更新し、手動での転記を排除する
  • 安全在庫数を設定してアラートを自動化し、発注判断の遅れを防ぐ

結果として、800SKUの在庫管理が5名のスタッフでも無理なく回せるようになり、売り越しゼロという成果につながりました。

株式会社ファストコーディングでは、kintoneのカスタマイズや業務アプリの構築を承っています。お問い合わせフォームからお気軽にご連絡ください。