【無料でできる】在庫が減ったらLINEに自動通知!Googleフォーム×スプレッドシート×GAS×LINE Message API

しんITサポートです。今回は前回作成した自動在庫管理表を使い、GASとLINE MessageAPI を連携させて、在庫が補充数より少なくなった場合にLINEに通知する仕組みを作ってみたいと思ます。

まだ、前回の自動在庫管理表のブログを見られていない方はこちらをご参照ください。

【無料テンプレ付き】Googleフォーム+スプレッドシートで自動在庫管理!

サンプルのスプレッドシートとフォームはこちらです。

スプレッドシート
グーグルフォーム

こんなお悩みありませんか?

  • 在庫切れに気づくのが遅れて発注が遅れがち
  • 毎日シートを目視チェックするのが面倒
  • 外出中でもスマホにアラートが来てほしい

この記事では、在庫数 ≤ 補充目安になった商品をLINEに自動通知する仕組みを、無料ツールだけで作ります。


今回つくる仕組み(完成イメージ)

  1. 在庫管理シート(見出し4行目/D列=在庫数、E列=補充目安)を定期チェック
  2. 条件に該当した商品をまとめてテキスト通知
  3. 通知は1商品あたり1日1回(重複防止)
  4. プレビュー送信で本番前にメッセージを確認可能

事前準備(10分)

  • Googleアカウント(スプレッドシート&Apps Script使用)
  • LINE Developers で Messaging API チャネル作成
  • チャネルアクセストークン(長期) を取得
  • 通知を受けるLINEアカウントでBotを友だち追加
  • 自分の userId(Uで始まるID) を取得
    • Webhookで受け取る/または一時的に応答に userId を返信させる等

シート前提(この記事の想定)

  • シート名:在庫一覧
  • 見出し:4行目
    • A: 商品ID / B: 商品名 / C: 単価 / D: 在庫数 / E: 補充目安 / F: 在庫金額
  • データ:5行目以降
    ※列名が違う場合は、後述の CONFIG を変更してください。

手順①:GASプロジェクトを作る

  1. スプレッドシートを開く → 拡張機能Apps Script
  2. エディタが開いたら、下のコードを丸ごと貼り付け → 保存(プロジェクト名は任意)

手順②:コードを貼る(そのまま動きます)

/**
 * 在庫が「補充目安」以下になったら LINE Message API で通知する
 * 想定シート: 「在庫一覧」
 * 必須列: 商品ID / 商品名 / 在庫数 / 補充目安
 *
 * 通知の重複防止:
 * - 同じ商品の通知は1日1回まで(スクリプトプロパティに当日付で記録)
 * - resetAlerts() を実行すると全商品分の通知フラグをリセット
 */

/**** ▼▼ 設定(ここを書き換える) ▼▼ ****/

const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("CHANNEL_ACCESS_TOKEN");
const USER_ID_1 = PropertiesService.getScriptProperties().getProperty("USER_ID_1");

const CONFIG = {
  SHEET_NAME: '在庫一覧',
  COLS: {              // 見出し名 → 列インデックスを自動取得するのでここは見出し名を合わせるだけ
    id: '商品ID',
    name: '商品名',
    stock: '在庫数',
    threshold: '補充目安',
  },
  LINE: {
    CHANNEL_ACCESS_TOKEN: CHANNEL_ACCESS_TOKEN,
    USER_IDS: [
      // 通知を送るユーザーの userId(複数可)
      // 例: 'Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
      USER_ID_1
    ],
    ENDPOINT_PUSH: 'https://api.line.me/v2/bot/message/push',
  },
  TZ: Session.getScriptTimeZone(), // 例: 'Asia/Tokyo'
  DRY_RUN: false,                  // true にすると通知せずログだけ出す
};

/**** ▲▲ 設定ここまで ▲▲ ****/


/**
 * メイン: 定期実行用(時間主導トリガーに割り当て推奨)
 */
function checkStockAndNotify() {
  const ss = SpreadsheetApp.getActive();
  const sh = ss.getSheetByName(CONFIG.SHEET_NAME);
  if (!sh) throw new Error('在庫一覧シートが見つかりません: ' + CONFIG.SHEET_NAME);

  // ヘッダー行の列位置を特定 (4行目)
  const header = sh.getRange(4, 1, 1, sh.getLastColumn()).getValues()[0];
  const colIdx = getColumnIndexMap_(header);

  // データ取得 (画像から5行目以降と判断)
  const lastRow = sh.getLastRow();
  if (lastRow < 5) return; // データなし
  const values = sh.getRange(5, 1, lastRow - 4, sh.getLastColumn()).getValues();

  const today = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM-dd');
  const props = PropertiesService.getScriptProperties();

  const alerts = []; // [{id, name, stock, threshold}, ...]

  values.forEach(row => {
    const id = readCell_(row, colIdx, CONFIG.COLS.id);
    const name = readCell_(row, colIdx, CONFIG.COLS.name);
    const stock = toNumber_(readCell_(row, colIdx, CONFIG.COLS.stock));
    const threshold = toNumber_(readCell_(row, colIdx, CONFIG.COLS.threshold));

    if (id === '' && name === '') return; // 空行ガード
    if (!isFinite(stock) || !isFinite(threshold)) return;

    if (stock <= threshold) {
      const key = `alert:${id || name}`;
      const lastSent = props.getProperty(key);
      if (lastSent !== today) {
        alerts.push({ id, name, stock, threshold, key });
      }
    }
  });

  if (alerts.length === 0) {
    console.log('在庫不足の対象なし');
    return;
  }

  // メッセージ生成(まとめ通知)
  const lines = [];
  lines.push('【在庫アラート】補充目安を下回りました');
  alerts.forEach(a => {
    lines.push(`・${a.name || a.id}:残り ${a.stock}(目安 ${a.threshold})`);
  });
  const text = lines.join('\n');

  if (CONFIG.DRY_RUN) {
    console.log('[DRY_RUN] 送信予定メッセージ:\n' + text);
  } else {
    // LINEへ送信(全USERに同文を Push)
    CONFIG.LINE.USER_IDS.forEach(userId => {
      sendLinePush_(userId, [{ type: 'text', text }]);
    });
  }

  // 本日分として通知済みフラグを記録
  const todayToRecord = Utilities.formatDate(new Date(), CONFIG.TZ, 'yyyy-MM-dd');
  alerts.forEach(a => props.setProperty(a.key, todayToRecord));
}


/**
 * 単品テスト: 任意の1商品分のテスト通知を送る(文面確認用)
 */
function sendTestNotification() {
  const msg = [
    { type: 'text', text: '【テスト】在庫アラートの送信確認です' }
  ];
  if (CONFIG.DRY_RUN) {
    console.log('[DRY_RUN] テストメッセージ:\n' + msg[0].text);
    return;
  }
  CONFIG.LINE.USER_IDS.forEach(userId => sendLinePush_(userId, msg));
}


/**
 * 1日の通知フラグをリセット(「今日はもう送った扱い」をクリア)
 * ・毎朝実行したい場合は時間トリガーにセット
 * ・毎日リセットせず「1商品1回だけ通知でOK」なら不要
 */
function resetAlerts() {
  const props = PropertiesService.getScriptProperties();
  const all = props.getProperties();
  Object.keys(all).forEach(k => {
    if (k.startsWith('alert:')) props.deleteProperty(k);
  });
  console.log('在庫アラートの通知フラグをリセットしました');
}


/** --- 内部ヘルパー --- **/

function getColumnIndexMap_(header) {
  const map = {};
  header.forEach((h, i) => (map[String(h).trim()] = i));
  // 必須列チェック
  ['id', 'name', 'stock', 'threshold'].forEach(key => {
    const label = CONFIG.COLS[key];
    if (!(label in map)) {
      throw new Error(`ヘッダー「${label}」が見つかりません(シート: ${CONFIG.SHEET_NAME})`);
    }
  });
  return map;
}

function readCell_(row, colIdx, label) {
  const i = colIdx[label];
  return i == null ? '' : row[i];
}

function toNumber_(v) {
  const n = Number(v);
  return isFinite(n) ? n : NaN;
}

function sendLinePush_(userId, messages) {
  const payload = {
    to: userId,
    messages: messages,
  };
  const params = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      Authorization: 'Bearer ' + CONFIG.LINE.CHANNEL_ACCESS_TOKEN
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };
  const res = UrlFetchApp.fetch(CONFIG.LINE.ENDPOINT_PUSH, params);
  const code = res.getResponseCode();
  const body = res.getContentText();
  if (code >= 300) {
    throw new Error(`LINE Push 失敗 code=${code} body=${body}`);
  } else {
    console.log('LINE Push 成功:', body);
  }
}

/**
 * 実データを使って在庫アラートの「プレビュー通知」を自分に送る
 * - 通知済みフラグは書き換えない(本番ロジックに影響なし)
 * - 在庫数 <= 補充目安 の行が対象
 */
function previewStockAlertToMe() {
  const ss = SpreadsheetApp.getActive();
  const sh = ss.getSheetByName(CONFIG.SHEET_NAME);
  if (!sh) throw new Error('在庫一覧シートが見つかりません: ' + CONFIG.SHEET_NAME);

  // ヘッダーの取得範囲を修正 (画像から4行目と判断)
  const header = sh.getRange(4, 1, 1, sh.getLastColumn()).getValues()[0];
  const colIdx = getColumnIndexMap_(header);

  const lastRow = sh.getLastRow();
  if (lastRow < 5) {
    sendPreviewText_('【在庫アラート(プレビュー)】対象データがありません');
    return;
  }

  // データの取得範囲を修正 (画像から5行目以降と判断)
  const values = sh.getRange(5, 1, lastRow - 4, sh.getLastColumn()).getValues();

  const alerts = [];
  values.forEach(row => {
    const id = readCell_(row, colIdx, CONFIG.COLS.id);
    const name = readCell_(row, colIdx, CONFIG.COLS.name);
    const stock = toNumber_(readCell_(row, colIdx, CONFIG.COLS.stock));
    const threshold = toNumber_(readCell_(row, colIdx, CONFIG.COLS.threshold));
    if ((id === '' && name === '') || !isFinite(stock) || !isFinite(threshold)) return;
    if (stock <= threshold) alerts.push({ id, name, stock, threshold });
  });

  if (alerts.length === 0) {
    sendPreviewText_('【在庫アラート(プレビュー)】現在、補充目安以下の在庫はありません');
    return;
  }

  const lines = [];
  lines.push('【在庫アラート(プレビュー)】補充目安を下回っています');
  alerts.forEach(a => lines.push(`・${a.name || a.id}:残り ${a.stock}(目安 ${a.threshold})`));
  sendPreviewText_(lines.join('\n'));
}

/** プレビュー通知を自分(CONFIG.LINE.USER_IDS[0])に送る */
function sendPreviewText_(text) {
  const to = CONFIG.LINE.USER_IDS[0];
  if (!to) throw new Error('CONFIG.LINE.USER_IDS にあなたの userId を設定してください');
  const msg = [{ type: 'text', text }];
  if (CONFIG.DRY_RUN) {
    console.log('[PREVIEW/DRY_RUN] ' + text);
    return;
  }
  sendLinePush_(to, msg); // 既存の送信関数を再利用
}

置き換えポイント:
CHANNEL_ACCESS_TOKEN長期トークンUSER_IDS自分の userId を必ずセット。

コード補足

実行の流れ(メイン関数)

checkStockAndNotify()

  1. シートを開く → 4行目の見出しを読み、各列の位置を把握
  2. 5行目以降のデータを走査
  3. 在庫数 <= 補充目安 の行を抽出
  4. 重複防止alert:商品ID のキーで当日日付が未記録のものだけ送信対象に該当商品を1通にまとめたテキストを作成
  5. LINE Message APIPush送信
  6. 送った商品について当日分をプロパティに記録(同日の二重送信を防止)

手順③:スクリプト プロパティの設定

次に事前に作成した チャネルアクセストークン(長期)UserId を設定します。

プロジェクトの設定 > スクリプトプロパティを編集 で以下のプロパティと値を設定します。

プロパティ:CHANNEL_ACCESS_TOKEN
値:取得したアクセストークン

プロパティ:USER_ID_1
値:取得した userId(Uで始まるID)

以上で設定は完了なので次から動作チェックしていきます。

手順④:動作チェック

  1. GASの関数プルダウンから sendTestNotification 実行 → LINEに届けばOK
    ※ LINE通知テストのみ
  2. シートで「在庫数 ≤ 補充目安」の商品を作る
  3. previewStockAlertToMe 実行 → プレビュー通知を確認(フラグ更新なし)
    ※ こちらは実データを使ってテスト
  4. 問題なければ checkStockAndNotify を実行 → 本番通知

手順④:自動化(トリガー設定)

  • GAS左の時計アイコン → checkStockAndNotify を毎朝8:00〜9:00 などに設定
  • (任意)resetAlerts を毎朝7:00~8:00に設定すると「1日1回通知」運用が安定します

よくあるエラーと対処

  • 401 Authentication failed:トークンが無効/別チャネルのトークン。長期トークンを再発行して貼り直し
  • 403:Botを友だち追加していない/ブロック中
  • 400userId の誤り(全角・空白混入) or ペイロード形式違い
  • 通知が多すぎる:通知間隔のロジックを拡張(例:3日に1回)/プレビューで確認してから本番

便利な応用(あとから拡張OK)

  • 複数人に送信(USER_IDS に追加)
  • 商品ごとに発注URLを付けて、通知から即発注
  • フォーム送信トリガーでほぼリアルタイム通知

まとめ

  • 「在庫数 ≤ 補充目安」を自動で検知 → LINE通知
  • 通知は1商品/日1回で重複を防止、プレビューで安全確認
  • 一度設定すれば、あとは放置運用で発注漏れを防げます

**「設定がうまくいかない」「自社用にカスタムしたい」**ときは、気軽にご相談ください。
(例:列名が違う、複数拠点で使いたい、責任者だけ別メッセージ など)

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

CAPTCHA



reCaptcha の認証期間が終了しました。ページを再読み込みしてください。

目次