路上弁当の予約受付をLINEで自動化する
友だち追加から予約フォーム・台帳への自動取込・前日リマインドまで。手を動かせば同じ仕組みが作れます。
1. 全体設計とスプレッドシートの準備
路上での弁当販売は「今日はどこに出ているのか」「売り切れる前に取り置きしたい」という問い合わせが電話とSNSに分散しがちです。ここではその受付をLINEに一本化し、注文内容がそのままスプレッドシートの台帳に積み上がる仕組みを作ります。
役割分担(ここを間違えると動かない)
LINE公式アカウントの Webhook(外部へ通知を送る先)は1つしか設定できません。エルメを使う時点でその1枠はエルメが占有します。したがってGASに「LINEからの受信」をさせることはできず、お客様個人へのLINE送信もGASには任せられません(ユーザーIDを取る手段がないため)。
| コンポーネント | 担当 | この記事での役割 |
|---|---|---|
| エルメ | 受信・自動応答・フォーム・リマインド配信・タグ付与 | 予約フォーム(章3)、前日リマインド(章5)、一斉配信・リッチメニュー(章6) |
| 連携シート | エルメがフォーム回答を自動記録 | 回答の受け口(人は触らない) |
| GAS(時間トリガー専用) | 受信も個人への送信もしない | 連携シート→台帳の取込・採番・単価付与、店主へのメール(章4) |
| スプレッドシート | 注文の保存と集計 | 注文台帳・メニューマスタ |
スプレッドシートを2枚用意する
エルメの連携シート(章3で自動生成)とは別に、人が使うシートを2枚作ります。ここで決めた列名と列順は、以降のGASコード・本文の説明・図表すべてで一致させます。後から列を挿入するとGASの列番号がずれるので、先に確定させてください。
シート①:注文台帳
| 列 | 列名 | 型 | 備考 |
|---|---|---|---|
| A | 注文ID | 文字列 | GASが採番(例 B261011-001) |
| B | 受付日時 | 日時 | 連携シートの回答日時をGASが写す |
| C | LINE表示名 | 文字列 | 連携シートからGASが写す |
| D | 受取日 | 日付 | フォームの日付回答をGASが写す |
| E | 受取場所 | 文字列 | 販売場所名 |
| F | メニュー名 | 文字列 | メニューマスタと一致 |
| G | 単価 | 数値 | GASがマスタから引く |
| H | 個数 | 数値 | フォームの回答(1〜3) |
| I | 金額 | 数値 | G × H をGASが計算 |
| J | ステータス | 列挙 | 受付/受渡済/キャンセル(店主が更新) |
| K | 取込キー | 文字列 | GASが記入(同じ回答を二重に写さないため) |
シート②:メニューマスタ
| 列 | 列名 | 例 |
|---|---|---|
| A | メニューコード | higawari |
| B | メニュー名 | 日替わり弁当 |
| C | 単価 | 550 |
| D | 販売状況 | 販売中/休止 |
タグの命名規則を1つ決める
エルメのタグはカテゴリ_状態の形に統一します。表記が揺れると絞り込み配信が効かなくなるため、この記事では以下の5つだけを使います。
| タグ名 | 付与タイミング | 用途 |
|---|---|---|
友だち_新規 | 友だち追加時(章2) | 初回案内の配信対象 |
流入_店頭 | 販売台のQRから追加 | 設置場所ごとの効果測定 |
流入_チラシ | チラシのQRから追加 | チラシ配布の効果測定 |
予約_確定 | フォーム回答時(章3) | 一度でも予約した人(リピーター向け配信の絞り込み) |
予約_キャンセル | キャンセル申告時(章7) | 前日リマインドを止める条件。次の予約で自動的に外れる |
友だち_新規や予約_確定は、あとから何があっても外しません。外すと「登録したが一度も注文していない人」「注文したことがある人」の絞り込みができなくなるためです。外したり付けたりするのは、状態を表す予約_キャンセルだけです。2. 友だち追加とウェルカムメッセージ
QRコードを読み取って友だち追加した瞬間に、あいさつメッセージが自動で届くようにします。エルメの「流入経路」を使うと、どのQRコードから登録されたかをタグで区別できます。販売台のPOPとチラシ、どちらが効いているかが数字で見えるようになります。
流入経路をつくる
操作場所:エルメ管理画面 > 友だち管理 > 流入経路
- 「新規作成」を押し、流入経路名に「販売台POP」と入力する
- 「友だち追加時に付与するタグ」に
友だち_新規と流入_店頭を指定する - 「友だち追加時のメッセージ」に、下のテンプレを貼り付ける
- 保存すると専用のQRコードとURLが発行されるので、QRコードを画像でダウンロードする
- 同じ手順をもう一度行い、名前「チラシ」・タグ
友だち_新規/流入_チラシで2本目を作る - 販売台のPOPには1本目、配布チラシには2本目のQRを印刷して使い分ける
コピペ用テンプレ(ウェルカムメッセージ)
3. 注文受付フォーム(受取日・場所・メニュー)
エルメのフォームを使い、1画面で予約が完了する入口を作ります。フォームにした理由は2つあります。回答がGoogleスプレッドシートに自動で記録されること(章4)、そして受取日を「日付」として受け取れるので、エルメのリマインド配信がそのまま使えること(章5)です。
受付の締切を先に決める
ここを曖昧にすると、章5のリマインドが成立しなくなります。締切とリマインド時刻はセットで設計します。
| 受取の種類 | 受付締切 | お知らせ |
|---|---|---|
| 当日受取 | 当日 10:30 まで | フォーム回答後の確認メッセージのみ(販売まで数時間なのでリマインドは送らない) |
| 翌日受取 | 前日 20:00 まで | 前日 20:30 にリマインド配信(章5) |
フォームを作る
操作場所:エルメ管理画面 > フォーム > フォーム作成
- 「新規作成」を押し、フォーム名を「お弁当予約」にする
- 質問1「受取日」を日付形式で作り、リマインド配信の基準にする設定(リマインド形式)をオンにする。説明文に「本日または明日をお選びください」と書く
- 質問2「受取場所」を選択形式で作り、販売場所を選択肢に並べる
- 質問3「お弁当」を選択形式で作り、選択肢を章1のメニューマスタのメニュー名と一字一句同じにする
- 質問4「個数」を選択形式で作り、「1」「2」「3」を選択肢にする
- 回答後のアクションに「タグ付与:
予約_確定」と「タグ解除:予約_キャンセル」を設定する - 回答後のメッセージに、下のテンプレを貼る
- 保存したら、フォーム一覧の「スプレッドシート」からGoogleアカウントを連携し、回答が記録されるシートを確認する(章4で使う)
コピペ用テンプレ(回答後メッセージ)
4. 注文をスプレッドシートの台帳に自動で積む
章3のフォーム回答は、エルメの連携でスプレッドシートにリアルタイムで記録されます。ただしその「連携シート」はエルメが書き込む受け口なので、人は手を触れません。GASが5分おきに新しい回答を拾い、採番と単価の付与をして注文台帳に移します。このGASは受信をしません。時間が来たらシートを読んで写すだけなので、エルメのWebhookとは無関係です。
シートの役割分担
| シート | 誰が書くか | 役割 |
|---|---|---|
| 連携シート(エルメが作る) | エルメだけ | フォーム回答の受け口。人は編集しない |
注文台帳 | GASと店主 | 採番・単価・ステータスを持つ正本 |
メニューマスタ | 店主 | メニュー名と単価 |
GASを設置する
- 章3で連携したスプレッドシート(連携シートが入っているファイル)を開く
- 同じファイルに、章1の
注文台帳とメニューマスタのシートを作る(別ファイルでもよいが、同じファイルにすると設定が1つ減る) - 拡張機能 > Apps Script を開き、エディタの中身をすべて消して下のコードを貼り付ける
- 冒頭の
SHEET_LINKEDを、エルメが作った連携シートのシート名に書き換える - 冒頭の
OWNER_MAILを、店主の通知先メールアドレスに書き換える - 関数
importOrdersを一度手動で実行し、権限の確認画面で許可する - 左メニューの時計アイコン(トリガー)から
importOrdersを「時間主導型 > 分ベースのタイマー > 5分おき」で登録する - 同じ画面で
morningReportを「日付ベースのタイマー > 午前10時〜11時」で登録する
GASコード(取込・採番・朝の準備数通知)
// ===== 路上弁当 注文取込GAS(時間トリガー専用・受信しない) =====
// 連携シート(エルメが書く)→ 注文台帳(人が使う)へ新しい回答を写す。
// LINEのWebhookはエルメが占有したままなので、このGASは受信を担当しない。
const SHEET_LINKED = 'お弁当予約'; // エルメが作った連携シートの名前に合わせる
const SHEET_ORDER = '注文台帳';
const SHEET_MENU = 'メニューマスタ';
const OWNER_MAIL = 'owner@example.com'; // 店主の通知先
// 連携シートの見出し名(フォームの質問名と一致させる)
const H = { AT: '回答日時', NAME: '名前', DAY: '受取日', PLACE: '受取場所', MENU: 'お弁当', QTY: '個数' };
// 注文台帳の列番号(章1の表と一致させること)
const COL = { ID: 1, RECEIVED_AT: 2, NAME: 3, PICKUP_DAY: 4, PLACE: 5, MENU: 6,
UNIT_PRICE: 7, QTY: 8, AMOUNT: 9, STATUS: 10, KEY: 11 };
// 5分おき:連携シートの未取込行を台帳へ
function importOrders() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const linked = ss.getSheetByName(SHEET_LINKED);
const order = ss.getSheetByName(SHEET_ORDER);
if (!linked || linked.getLastRow() < 2) return;
const data = linked.getDataRange().getValues();
const head = data[0].map(String);
const idx = function (name) { return head.indexOf(name); };
const c = { at: idx(H.AT), name: idx(H.NAME), day: idx(H.DAY), place: idx(H.PLACE), menu: idx(H.MENU), qty: idx(H.QTY) };
if (Object.keys(c).some(function (k) { return c[k] < 0; })) {
mail('【要確認】連携シートの見出しが見つかりません', '見出し: ' + head.join(' / '));
return;
}
const done = existingKeys(order);
const menus = loadMenus(ss);
const alerts = [];
for (let i = 1; i < data.length; i++) {
const r = data[i];
const key = ymdhms(r[c.at]) + '|' + r[c.name] + '|' + ymd(r[c.day]) + '|' + r[c.menu];
if (!r[c.at] || done[key]) continue; // 空行・取込済みは飛ばす
const menu = menus[String(r[c.menu]).trim()];
const qty = Number(r[c.qty] || 1);
const price = menu ? menu.price : '';
const amt = menu ? menu.price * qty : '';
order.appendRow([
makeOrderId(order), // A 注文ID
new Date(r[c.at]), // B 受付日時
r[c.name], // C LINE表示名
new Date(r[c.day]), // D 受取日
r[c.place], // E 受取場所
r[c.menu], // F メニュー名
price, // G 単価
qty, // H 個数
amt, // I 金額
'受付', // J ステータス
key // K 取込キー(重複防止)
]);
done[key] = true;
if (!menu) alerts.push('メニュー名がマスタにありません: ' + r[c.menu] + '(' + r[c.name] + ')');
const warn = checkDeadline(new Date(r[c.at]), new Date(r[c.day]));
if (warn) alerts.push(warn + '(' + r[c.name] + ' / ' + ymd(r[c.day]) + ')');
}
if (alerts.length) mail('【要確認】お弁当予約の取込', alerts.join('\n'));
}
// 締切チェック:当日は10:30まで、翌日は前日20:00まで、明後日以降は受けない
function checkDeadline(receivedAt, pickupDay) {
const diff = Math.round((startOfDay(pickupDay) - startOfDay(receivedAt)) / 86400000);
const hm = receivedAt.getHours() * 100 + receivedAt.getMinutes();
if (diff < 0) return '受取日が過去の日付です';
if (diff > 1) return '受取日が明後日以降です';
if (diff === 0 && hm > 1030) return '当日受取の締切(10:30)を過ぎた予約です';
if (diff === 1 && hm > 2000) return '翌日受取の締切(20:00)を過ぎた予約です(前日リマインドは届きません)';
return '';
}
// 毎朝10時台:当日ぶんの準備数を店主へメール
function morningReport() {
const order = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_ORDER);
const last = order.getLastRow();
if (last < 2) return;
const rows = order.getRange(2, 1, last - 1, COL.KEY).getValues();
const today = ymd(new Date());
const sum = {};
let total = 0;
rows.forEach(function (r) {
if (ymd(r[COL.PICKUP_DAY - 1]) !== today) return;
if (r[COL.STATUS - 1] !== '受付') return;
const q = Number(r[COL.QTY - 1] || 0);
sum[r[COL.MENU - 1]] = (sum[r[COL.MENU - 1]] || 0) + q;
total += q;
});
const lines = Object.keys(sum).map(function (m) { return m + ':' + sum[m] + '個'; });
mail('本日の予約 ' + total + '個(' + today + ')', lines.join('\n') || '本日の予約はありません');
}
function loadMenus(ss) {
const rows = ss.getSheetByName(SHEET_MENU).getDataRange().getValues();
const map = {};
for (let i = 1; i < rows.length; i++) {
if (rows[i][3] === '販売中') map[String(rows[i][1]).trim()] = { price: Number(rows[i][2]) };
}
return map;
}
function existingKeys(sheet) {
const last = sheet.getLastRow();
const map = {};
if (last < 2) return map;
sheet.getRange(2, COL.KEY, last - 1, 1).getValues().forEach(function (r) { if (r[0]) map[r[0]] = true; });
return map;
}
// 注文IDを採番(例 B261011-001)
function makeOrderId(sheet) {
const prefix = 'B' + Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyMMdd') + '-';
const last = sheet.getLastRow();
let seq = 0;
if (last >= 2) {
sheet.getRange(2, COL.ID, last - 1, 1).getValues().forEach(function (r) {
const v = String(r[0]);
if (v.indexOf(prefix) === 0) seq = Math.max(seq, Number(v.slice(prefix.length)));
});
}
return prefix + ('00' + (seq + 1)).slice(-3);
}
function mail(subject, body) { MailApp.sendEmail(OWNER_MAIL, subject, body); }
function startOfDay(d) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }
function ymd(d) { return d ? Utilities.formatDate(new Date(d), 'Asia/Tokyo', 'yyyy/MM/dd') : ''; }
function ymdhms(d) { return d ? Utilities.formatDate(new Date(d), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss') : ''; }
H に書いた「回答日時」「名前」などは、エルメが作る連携シートの1行目に合わせます。質問名を変えたら、ここも同じに変えます。見出しが見つからないときは取り込まずに店主へメールが届くので、黙って止まることはありません。5. 前日リマインド(エルメのリマインド配信)
取り置きの一番の悩みは「予約したまま来ない」ことです。前日の夜にひと言届くだけで、取りに来る率は目に見えて変わります。送るのはGASではなくエルメのリマインド配信です。章3のフォームで受取日を「リマインド形式」にしてあるので、その日付から逆算して自動で送られます。
GASに個人へのLINE送信をさせない理由
エルメを接続していると、LINEのWebhookはエルメが占有します。その状態でGASがお客様個人にpushを送るには、LINEのユーザーIDをGASが知っている必要がありますが、Webhookを持たないGASにはそれを取る手段がありません。無理に取ろうとする設計は、たいていエルメの接続を壊します。個人へ送る役はエルメ、人(店主)へ知らせる役はGASのメール、と分けるのがこの記事の原則です。
リマインド配信を作る
操作場所:エルメ管理画面 > 配信 > リマインド配信
- 「新規作成」を押し、配信名を「お弁当 前日リマインド」にする
- リマインドの基準(終了日)に、章3のフォーム「お弁当予約」の質問「受取日」を選ぶ
- 配信タイミングを「1日前の 20:30」にする
- 配信条件に「タグ
予約_キャンセルが付いていない」を設定する(キャンセルした人には送らない) - 本文に下のテンプレを貼って保存し、有効にする
コピペ用テンプレ(前日リマインド)
6. 販売スケジュール通知とリッチメニュー
予約をしていないお客様にも「今日はここに出ています」を届けます。配信は毎朝9時。リッチメニューはトーク画面の下に常に出ているボタンなので、思い立ったときすぐ予約に入れます。
一斉配信を予約する
操作場所:エルメ管理画面 > 配信 > 一斉配信
- 「新規作成」を押し、配信名を「本日の販売案内」にする
- 配信対象を「全員」にする(絞るなら
友だち_新規を含む条件で) - 配信方法を「日時指定」にし、当日の 9:00 を設定する
- 本文に下のテンプレを貼り、場所とメニューを当日の内容に書き換える
- 本文の下にボタンを追加し、章3のフォームのURLを開く動作にする
無料枠200通の数え方
LINE公式アカウントの無料(コミュニケーション)プランは月200通までです。数えられるのはこちらから送るPushで、相手の操作に返すReplyは数えられません。この記事の仕組みを分けると次のとおりです。
| この記事のメッセージ | 種類 | 通数 |
|---|---|---|
| あいさつメッセージ(章2) | Reply | 数えない |
| リッチメニューのタップで返す案内(章6・7) | Reply | 数えない |
| キーワード応答(章7) | Reply | 数えない |
| フォーム回答後メッセージ(章3) | Push | 1通 |
| 前日リマインド(章5) | Push | 1通 |
| 本日の販売案内(章6) | Push | 友だち数ぶん |
コピペ用テンプレ(本日の販売案内)
リッチメニューを作る
操作場所:エルメ管理画面 > 友だち管理 > リッチメニュー
| 位置 | ラベル | 動作 | つながる先 |
|---|---|---|---|
| 左上 | 予約する | 章3のフォームURLを開く | お弁当予約フォーム |
| 右上 | 今日の販売場所 | テキスト「販売場所」を送信 | キーワード応答(当日の案内) |
| 左下 | 予約の確認 | テキスト「予約確認」を送信 | 章7のキーワード応答 |
| 右下 | お問い合わせ | テキスト「問い合わせ」を送信 | キーワード応答(連絡先の案内) |
- 「新規作成」を押し、テンプレートから2×2の4分割を選ぶ
- 1200×810ピクセルの画像を用意してアップロードする
- 4つの領域それぞれに、上の表のとおり動作を設定する
- 表示設定を「全員に表示」にして、公開する
- LINE公式アカウントの管理画面側で作ったリッチメニューがあれば、オフにする(エルメのものと二重になるため)
7. 予約の確認・キャンセル・受渡し済み管理
章6のリッチメニューに「予約の確認」を置いたので、その受け皿を作ります。あわせて、販売現場での受渡し記録と当日の準備数の数え方をまとめます。
お客様側:予約の確認とキャンセル
操作場所:エルメ管理画面 > 自動応答 > キーワード応答
- キーワード「予約確認」で発動する応答を新規作成する
- 返信本文に、章3のフォーム回答(受取日・受取場所・お弁当・個数)を差し込む。本文の下に「この予約をキャンセルする」ボタンを置き、テキスト「キャンセル」を送信する動作にする
- キーワード「キャンセル」で発動する応答を作り、「キャンセルを承りました」の一言と、タグ付与
予約_キャンセルを設定する - 店主がキャンセルにすぐ気づけるよう、顧客対応 > 通知設定 でチャットの通知をスマホアプリに出す設定にしておく
予約_キャンセル が付いた時点で章5のリマインドは止まるので、お客様への連絡は自動で正しく止まります。店主側:受渡しの記録
- 販売中はスマホで
注文台帳を開いておく - お客様が来たら、その行のJ列を「受付」から「受渡済」に変える
- 閉店後、J列が「受付」のまま残っている行が受け取りに来なかった予約になる
当日の準備数を数える
章4の morningReport が毎朝メールで届けますが、シート上でも見たいときは、台帳の右側の空いているセル(例:M1)に次の式を貼っておくと、当日ぶんの必要数がメニュー別に自動で出ます。
=QUERY(注文台帳!D2:J,
"select F, sum(H)
where D = date '"&TEXT(TODAY(),"yyyy-mm-dd")&"'
and J = '受付'
group by F
label sum(H) '必要数'", 0)
数字の振り返りに使う式
| 見たいこと | 式の例 |
|---|---|
| 今日の予約売上 | =SUMIFS(注文台帳!I:I, 注文台帳!D:D, TODAY(), 注文台帳!J:J, "受渡済") |
| メニュー別の注文数 | =COUNTIF(注文台帳!F:F, "から揚げ弁当") |
| 受け取りに来なかった数 | =COUNTIFS(注文台帳!J:J, "受付", 注文台帳!D:D, "<"&TODAY()) |
| 場所別の予約数 | =COUNTIF(注文台帳!E:E, "中央公園前") |
まとめ
友だち追加から予約受付、台帳への自動取込、前日リマインド、受渡しの記録まで、路上弁当販売の一連の流れをLINEとスプレッドシートの2つだけで組みました。設計の勘所は3つです。
- LINEのWebhookはエルメが占有する。お客様個人に送るのはエルメ(回答後メッセージ・リマインド配信)、GASは連携シートから台帳への取込と店主へのメールに徹する
- スプレッドシートの列と、フォームの質問名と、タグ名は最初に1つずつ確定させ、以降ずらさない。メニューの選択肢はマスタと一字一句揃える
- 受付の締切とリマインドの時刻はセットで決める。片方だけ動かすと届かない予約が生まれる
最初に手をつけるなら、章1のスプレッドシート2枚を作るところからです。ここさえ固まっていれば、あとの章は上から順に積み上げるだけで動きます。


