今までの記事一覧
Googleフォーム×GASで実現する業務自動化シリーズ全記事まとめ目次
前提
この記事は、フォーム回答を保存しているスプレッドシート側のGASを前提にしています。
トリガーは以下を設定してください。
- 実行する関数:
onFormSubmit - イベントのソース:スプレッドシートから
- イベントの種類:フォーム送信時
スプレッドシート構成
スプレッドシート「集計シート」をこの図のように設定しています。
- A1:D6 セル:商品マスタ(商品コードや現在の残数が入っている)
- 11行目以下:フォームから送信された注文の受付内容が記録される
- 図の赤字の内容の数式が入っている
※以前作ったこの記事の数式をそのまま生かしています。ただし、変更フォームによって在庫不足による注文数の修正が発生したときのために、有効注文数の数式をほんの少しだけパワーアップさせ=IF(ISNUMBER(修正数のセル), 修正数のセル, 注文数のセル)としておきます。こうすることで、『修正数』の欄に修正後の確定数を書き込むだけで、有効注文数も、商品マスタの残数も、シート側で全自動で連動して計算されるようになります。
おさらい
前回は、変更フォームから送信された場合の挙動を作りました。
送信元のシート名で処理ルートを切り分け、過去の注文履歴を逆引きし、さらに、過去の注文数を一時的に棚に戻す仮想在庫の考え方を取り入れたことで、複数商品になっても破綻しないロジックが完成しました。
集計シートも、「注文数」「修正数」「有効注文数」という3つの列を活用して、送信された数と受付できた数のどちらも表示し、管理画面としての使いやすさも向上しました。
今回は、前回作成した allocatedMap と rejectedMap を利用して、受付結果に応じたメールを自動作成します。
今回のミッション
注文フォーム/変更フォーム、どちらからの送信であっても、以下の場合分けをしてメールを作成する。
① 全数受付可能の場合(全数受付メール)
- タイトル:お弁当の注文を受付しました
- 注文内容
- 変更フォームの事前入力URL
- 現在の残数状況
② 一部受付可能の場合(修正受付メール)
- タイトル:【重要】お弁当の注文数を一部修正しました
- 注文内容 残数不足のものはその旨記載
- 変更フォームの事前入力URL
- 現在の残数状況
③注文した商品は売り切れだが他の商品には在庫がある場合(在庫不足お断りメール)
- タイトル:【重要】お弁当のご注文を承れませんでした
- お詫び文章
- 変更フォームの事前入力URL
- 現在の残数状況
④ 在庫がすべて0の場合(完売御礼メール)
- タイトル:【完売御礼】お弁当はすべて売り切れとなりました
- お詫び文章
⑤ 注文数がすべて0(つまり注文キャンセル)の場合(キャンセル受付メール)
- タイトル:お弁当の注文キャンセルを受付しました
- キャンセル受付した旨の文章
今回のキモはコレ!
メール作成のサブ関数を作る
ここでもメール専用のサブ関数を作り、場合分けや本文作成を任せてしまいましょう。
これまでに作ったこれらの変数をメールで使うので、引数に入れておきます。
-
allocatedMap(注文OK分) -
rejectedMap(残数不足でお断り分) -
prefilledUrl(事前入力URL) -
itemMaster(シートから取得した商品マスタ) -
orderMap(注文内容) -
formDescriptionText(フォームタイトル下のこの説明文。メール本文に現在の在庫状況を載せるため、これを流用します)
メイン関数でこのようにしてサブ関数を呼び出します。
// 10.【サブ関数】動的メールの送信
sendDynamicMail(allocatedMap, rejectedMap, prefilledUrl, itemMaster, orderMap, formDescriptionText);
条件分岐ロジック
この図のようなフローで条件分岐していきます。
-
allocatedMapの中には、商品情報を追加する前に[EMAIL]と[NAME]を保存しているため、最低でも2つの要素が入っています。そこで、このコードを使って商品が入っているかを判定します。(要素数が3以上なら、商品が入っていると判断できる)
if (Object.keys(allocatedMap).length <= 2) {
- 以下のコードで
itemMasterの残数をすべて合計したtotalStockが0なら全商品売り切れと判断しています。
//すべての商品の残数を足し算
let totalStock = 0;
for (let i = 1; i < itemMaster[0].length; i++) {
totalStock += itemMaster[5][i];
}
- 同様に、以下のコードで
orderMap(注文内容)の数をすべて合計したtotalOrderが0ならキャンセルと判断しています。
// orderMap(注文内容)の合計数を計算
let totalOrder = 0;
for (const productCode in orderMap) {
totalOrder += orderMap[productCode];
}
条件分岐でメールの種類(mailType) を決定し、その結果を switch - case で振り分けて本文を作成します。
//注文内容や受付OK/NGによってどのメールにするかの場合分け
let mailType;
if (totalOrder === 0) { //注文内容の数が全部0(注文キャンセル)の場合
mailType = "cancel";
} else if (allocatedCount === 0) { //受付OKのものが0の場合
mailType = totalStock === 0
? "soldout" //なおかつ在庫がすべて0の場合
: "rejected"; //受付OKの商品は0だが他の商品ならある場合
} else if (rejectedCount === 0) { //お断り商品が0の場合
mailType = "accepted";
} else { //それ以外(受付OK商品もお断り商品もあった場合)
mailType = "partial";
}
switch (mailType) {
case "cancel":
//注文がキャンセルされた場合の内容
break;
case "accepted":
//注文が全部OKだった場合の内容
break;
//以下略
サブ関数コード
サブ関数のコードはこちらになります。
function sendDynamicMail(allocatedMap, rejectedMap, prefilledUrl, itemMaster, orderMap, formDescriptionText) {
const mail = allocatedMap["[EMAIL]"];
const user = allocatedMap["[NAME]"];
let title;
let body;
const time = Utilities.formatDate(new Date(), "JST", "yyyy/MM/dd HH:mm");
//すべての商品の残数を足し算
let totalStock = 0;
for (let i = 1; i < itemMaster[0].length; i++) {
totalStock += itemMaster[5][i];
}
// orderMap(注文内容)の合計数を計算
let totalOrder = 0;
for (const productCode in orderMap) {
totalOrder += orderMap[productCode];
}
//注文OKだった商品の数
const allocatedCount =
Object.keys(allocatedMap).length - 2; //もともとallocatedMapには「氏名」「メールアドレス」の2つが入っているため、商品数はこの2つを除いたもの
//残数不足でお断りした商品の数
const rejectedCount =
Object.keys(rejectedMap).length;
//注文内容や受付OK/NGによってどのメールにするかの場合分け
let mailType;
if (totalOrder === 0) { //注文内容の数が全部0(注文キャンセル)の場合
mailType = "cancel";
} else if (allocatedCount === 0) { //受付OKのものが0の場合
mailType = totalStock === 0
? "soldout" //なおかつ在庫がすべて0の場合
: "rejected"; //受付OKの商品は0だが他の商品ならある場合
} else if (rejectedCount === 0) { //お断り商品が0の場合
mailType = "accepted";
} else { //それ以外(受付OK商品もお断り商品もあった場合)
mailType = "partial";
}
//上で場合分けしたmailTypeごとにメール内容を作る
switch (mailType) {
case "cancel":
title = "お弁当の注文キャンセルを受付しました";
body = `${user} 様
いつもご利用ありがとうございます。
すべての商品が「0個」で送信されたため、
ご注文はキャンセルとして受け付けいたしました。
またのご利用を心よりお待ちしております。`;
break;
case "accepted":
title = "お弁当の注文を受付しました"
body = `${user} 様
ご注文ありがとうございます。
以下のとおりお弁当の注文を受付しました。
・ ハンバーグ弁当: ${allocatedMap["[HBG]"] ?? 0} 個
・ 唐揚げ弁当 : ${allocatedMap["[KRG]"] ?? 0} 個
・ 焼き魚弁当 : ${allocatedMap["[YKS]"] ?? 0} 個
▼ 注文内容の変更・キャンセルはこちらから
${prefilledUrl}
なお、${time} 現在の注文可能数は以下のとおりとなっております。
${formDescriptionText.replace("【現在の残数状況】", "")}
`
break;
case "partial":
title = "【重要】お弁当の注文数を一部修正しました"
body = `${user} 様
ご注文ありがとうございます。
恐れ入りますが、残数が不足のため、以下のとおり注文内容を変更して受け付けしました。
・ ハンバーグ弁当: ${allocatedMap["[HBG]"] ?? 0} 個 ${rejectedMap["[HBG]"] ? `(※在庫不足のため ${allocatedMap["[HBG]"] ?? 0} 個に修正)` : ""}
・ 唐揚げ弁当 : ${allocatedMap["[KRG]"] ?? 0} 個 ${rejectedMap["[KRG]"] ? `(※在庫不足のため ${allocatedMap["[KRG]"] ?? 0} 個に修正)` : ""}
・ 焼き魚弁当 : ${allocatedMap["[YKS]"] ?? 0} 個 ${rejectedMap["[YKS]"] ? `(※在庫不足のため ${allocatedMap["[YKS]"] ?? 0} 個に修正)` : ""}
▼ 注文内容の変更・キャンセルはこちらから
${prefilledUrl}
なお、${time} 現在の注文可能数は以下のとおりとなっております。
${formDescriptionText.replace("【現在の残数状況】", "")}
`
break;
case "rejected":
title = "【重要】お弁当のご注文を承れませんでした";
body = `${user} 様
ご注文ありがとうございます。
大変申し訳ございませんが、先に他のお客様のご注文が確定し、
ご希望いただいたお弁当の在庫が不足したため、今回はご注文を承ることができませんでした。
${time} 現在の注文可能数は以下のとおりとなっております。
${formDescriptionText.replace("【現在の残数状況】", "")}
在庫状況をご確認のうえ、必要でしたらこちらから再度注文をお願いします。
${prefilledUrl}
`
break;
case "soldout":
title = "【完売御礼】お弁当はすべて売り切れとなりました";
body = `${user} 様
ご注文ありがとうございます。
大変申し訳ございませんが、先に他のお客様のご注文が確定し、
すべてのお弁当が【完売】となりました。そのため、
◆今回はご注文を承ることができませんでした◆
またのご利用をお待ちしております。`
break;
}
GmailApp.sendEmail(mail, title, body);
}
動作確認
①全数受付可能の場合
まずはどの商品も残数に収まるような、次の内容の注文を送信してみます。
ハンバーグ:残数1 > 注文1
唐揚げ :残数10 > 注文3
焼き魚 :残数4 > 注文2

受け付け内容・変更用URL・現在の残数がきちんと表示されたメールが作成されました。
②一部残数不足の場合
この状態で、同じ人から変更フォームを使って次の内容を送信してみます。
ハンバーグ:残数0 → 注文3(残数不足のため、注文済の1しか受付できないはず)
唐揚げ :残数7 → 注文5(もともとの注文3から5に増量)
焼き魚 :残数2 → 注文2(もともとの注文2から変更なし)

ハンバーグ弁当が修正された旨のメールが作成されました。唐揚げ弁当も元々の注文3から5に数が増えていますが、こちらは残数以内なので5として受け付けされています。
③注文した商品は売り切れだが他の商品には在庫がある場合
この状態で別の人が次の内容で注文フォームを送信します。フォームを開いたまま熟考しているうちに商品が売り切れてしまったという想定です。注文はハンバーグ弁当のみですが、在庫切れのため「お断り」となるパターンです。
ハンバーグ:残数0 → 注文4(残数不足のため受付お断り)
唐揚げ :残数7 → 注文なし
焼き魚 :残数2 → 注文なし

注文を受け付けることができなかった旨とともに、現在の在庫状況と変更フォームのURLが記載されたメールが作成されました。
④在庫がすべて0の場合
こちらも、フォームを開いたまま入力を迷っているうちにすべての商品が売り切れてしまったという想定で、注文フォームでこの内容を送信してみます。
ハンバーグ:残数0 → 注文なし
唐揚げ :残数0 → 注文3
焼き魚 :残数0 → 注文2

すべての商品の残数が0のため、完売御礼でお断りメールが作成されました。
⑤注文数がすべて0(注文キャンセル)の場合
②で注文を送信した人が、変更フォームを使ってすべての商品を0で送ります。
まとめ
今回は、複数商品システムにおける「自動メール送信」の仕組みを実装しました。
単に「ご注文ありがとうございました」と一辺倒なメールを送るのではなく、裏側の在庫状況・引当結果・ユーザーの希望を照らし合わせ、mailType という1つの判定結果に集約して switch - case で切り替えるロジックを構築しました。
これによって、以下の5つのシチュエーションすべてにおいて、ユーザーに誤解を与えない「受付メール」を届けることができるようになりました。
- 全数受付メール:注文をそのまま受け付けたことを知らせるメール
- 修正受付メール:在庫不足により修正された受付内容を知らせるメール
- 在庫不足お断りメール:注文は受け付けられなかったものの、他商品の在庫状況を案内するメール
- 完売御礼メール:すべての商品が売り切れたことを知らせるメール
- キャンセル受付メール:すべての商品を「0個」で送信した場合のキャンセル受付メール
ここまでで、複数商品に対応した在庫引き当てシステムが完成しました。
とはいえ、実際に運用するには、毎日の注文データを整理したり、翌日の受付に向けて初期化したりする仕組みも必要になります。
次回からは、1日の注文結果のバックアップや自動リセットなど、実運用を支えるデイリー運用の自動化を実装していきます。
その前に、またちょっと寄り道をして、こちらの番外編に行こうと思います。ご興味があればぜひ覗いてみてください。
Googleフォーム×在庫管理!Googleフォーム×在庫管理!番外編 売り切れ商品をフォームから自動で隠す
今回の成果コード
※掲載しているのは、今回の変更内容を反映した完成版です。今回の説明対象ではないサブ関数は、これまでの記事で作成したものをそのまま利用しています。
メイン関数
function onFormSubmit(e) {
// 1. LockServiceで重複防止の鍵をかける
const lock = LockService.getScriptLock();
let mail = "";
let user = "";
try {
lock.waitLock(30000); // 最大30秒待機
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet_summary = ss.getSheetByName("集計シート");
const activeSheet = e.range.getSheet(); // 回答が書き込まれたシート
const sheetName = activeSheet.getName();
// フォーム送信データから基本情報を取得
mail = e.values[1]; // メールアドレス
user = e.values[2]; // 氏名
// 2. 集計シートの「商品マスタ(1〜6行目)」を一括取得
const summaryLastCol = sheet_summary
.getRange(1, sheet_summary.getMaxColumns())
.getNextDataCell(SpreadsheetApp.Direction.PREVIOUS)
.getColumn(); // 1行目の最終列を取得
// A列(見出し)も含めてデータを取得
let itemMaster = sheet_summary.getRange(1, 1, 6, summaryLastCol).getValues();
// 3. フォームから届いた注文数を商品コードベースでマッピングする
const orderMap = {};
for (let col = 1; col < summaryLastCol; col++) {
const productCode = itemMaster[1][col]; //商品コード([HBG]など)
let orderedNum = 0;
for (const questionTitle in e.namedValues) { //フォーム回答eからすべての質問文を抜き出す
if (questionTitle.includes(productCode)) { //質問文に該当の商品コードが出てきたら
const responseValue = e.namedValues[questionTitle][0]; //その商品の注文数を格納
orderedNum = Number(responseValue) || 0; //注文数をorderedNumに格納、responseValueがなければ0を格納
break;
}
}
// 注文マップに「商品コード」をキーにして数量を保存
orderMap[productCode] = orderedNum;
}
// 5. 集計シートへ書き込む「1行分のデータ(配列)」を組み立てる
const formattedTime = Utilities.formatDate(new Date(), "JST", "yyyy/MM/dd HH:mm:ss");
const rowData = [formattedTime, mail, user];
// 6. 集計シートの最終行に書き込み
const summaryLastRow = sheet_summary
.getRange(sheet_summary.getMaxRows(), 1)
.getNextDataCell(SpreadsheetApp.Direction.UP)
.getRow();
let editRow; //集計シートの書き込み先行を取得
// 4.在庫の仕分け【サブ関数】を実行
let allocatedMap = {}; //受付OKのもの
allocatedMap["[EMAIL]"] = mail;
allocatedMap["[NAME]"] = user;
let rejectedMap = {};
if (sheetName === "フォームの回答1") { //注文フォームからの送信の場合
editRow = summaryLastRow + 1;
// 在庫の仕分け【サブ関数】を実行し、結果をresultに入れる
const result = allocateInventory(orderMap, itemMaster, summaryLastCol, false, editRow, sheet_summary);
Object.assign(allocatedMap, result.allocatedMap);
rejectedMap = result.rejectedMap;
} else if (sheetName === "フォームの回答2") { //変更フォームからの送信の場合
//今までに注文した人のメールを全部取得して1次元配列化
const mailDataList = sheet_summary.getRange(5, 2, summaryLastRow - 4, 1).getValues().flat();
//今までの注文済みメールアドレスを下から検索して今回の注文と同じものの配列番号
const lastHitIndex = mailDataList.lastIndexOf(mail);
//過去に注文フォームで注文した形跡がない場合はお断りメール【サブ関数】rejectionCorrectionMail
if (lastHitIndex == -1) {
rejectionCorrectionMail(mail, user);
return;
}
// 前回のアドレスがある行を特定する
editRow = lastHitIndex + 5;
// 在庫の仕分け【サブ関数】を実行
const result = allocateInventory(orderMap, itemMaster, summaryLastCol, true, editRow, sheet_summary);
Object.assign(allocatedMap, result.allocatedMap); //あらかじめメールや氏名が入っている allocatedMap に受付内容を追加
rejectedMap = result.rejectedMap;
}
// 3つの列(注文数・修正数・有効注文数)に合わせてデータを組み立てる
for (let col = 1; col < summaryLastCol; col++) {
const productCode = itemMaster[1][col];
const originalOrder = orderMap[productCode] || 0; // 本来の希望数
const finalAllocated = allocatedMap[productCode] || 0; // 最終受付数
const rejectedNum = rejectedMap[productCode] || 0; // 在庫不足で削られた数
// 1列目:注文数(ユーザーが最初に入力した数)
rowData.push(originalOrder);
// 2列目:修正数(削られた数がある場合だけマイナス表記、通常時は空欄)
rowData.push(rejectedNum > 0 ? finalAllocated : "");
// 3列目:有効注文数(最終確定数をGASが直接書き込む)
rowData.push("");
}
// 組み立てたデータを書き込み
sheet_summary.getRange(editRow, 1, 1, rowData.length).setValues([rowData]);
//検証用にログ出力
Logger.log("注文受付内容:" + JSON.stringify(allocatedMap));
Logger.log("受付できなかった内容:" + JSON.stringify(rejectedMap));
// 7. 【サブ関数】変更フォーム用のプレ入力URLの組み立て(ここも受付できた数 allocatedMap を元に作成)
const prefilledUrl = createPrefilledUrl(allocatedMap);
// URL保存列(現在の最終列)へ記録
sheet_summary.getRange(editRow, sheet_summary.getLastColumn()).setValue(prefilledUrl);
// 8. スプレッドシートの関数(注文合計や残数)を強制再計算させる
SpreadsheetApp.flush();
//残数を書き直した状態のマスターデータを再度取得
itemMaster = sheet_summary.getRange(1, 1, 6, summaryLastCol).getValues();
// 9.【サブ関数】フォームの選択肢&説明欄を最新化
const formDescriptionText = updateFormOptions(itemMaster);
// 10.★★【サブ関数】動的メールの送信
sendDynamicMail(allocatedMap, rejectedMap, prefilledUrl, itemMaster, orderMap, formDescriptionText);
} catch (err) {
Logger.log(`エラー発生\n mail:${mail}\n user:${user}\n 内容:${err.stack}`);
} finally {
if (lock.hasLock()) lock.releaseLock();
}
}
サブ関数
function sendDynamicMail(allocatedMap, rejectedMap, prefilledUrl, itemMaster, orderMap, formDescriptionText) {
const mail = allocatedMap["[EMAIL]"];
const user = allocatedMap["[NAME]"];
let title;
let body;
const time = Utilities.formatDate(new Date(), "JST", "yyyy/MM/dd HH:mm");
//すべての商品の残数を足し算
let totalStock = 0;
for (let i = 1; i < itemMaster[0].length; i++) {
totalStock += itemMaster[5][i];
}
// orderMap(注文内容)の合計数を計算
let totalOrder = 0;
for (const productCode in orderMap) {
totalOrder += orderMap[productCode];
}
//注文OKだった商品の数
const allocatedCount =
Object.keys(allocatedMap).length - 2; //もともとallocatedMapには「氏名」「メールアドレス」の2つが入っているため、商品数はこの2つを除いたもの
//残数不足でお断りした商品の数
const rejectedCount =
Object.keys(rejectedMap).length;
//注文内容や受付OK/NGによってどのメールにするかの場合分け
let mailType;
if (totalOrder === 0) { //注文内容の数が全部0(注文キャンセル)の場合
mailType = "cancel";
} else if (allocatedCount === 0) { //受付OKのものが0の場合
mailType = totalStock === 0
? "soldout" //なおかつ在庫がすべて0の場合
: "rejected"; //受付OKの商品は0だが他の商品ならある場合
} else if (rejectedCount === 0) { //お断り商品が0の場合
mailType = "accepted";
} else { //それ以外(受付OK商品もお断り商品もあった場合)
mailType = "partial";
}
//上で場合分けしたmailTypeごとにメール内容を作る
switch (mailType) {
case "cancel":
title = "お弁当の注文キャンセルを受付しました";
body = `${user} 様
いつもご利用ありがとうございます。
すべての商品が「0個」で送信されたため、
ご注文はキャンセルとして受け付けいたしました。
またのご利用を心よりお待ちしております。`;
break;
case "accepted":
title = "お弁当の注文を受付しました"
body = `${user} 様
ご注文ありがとうございます。
以下のとおりお弁当の注文を受付しました。
・ ハンバーグ弁当: ${allocatedMap["[HBG]"] ?? 0} 個
・ 唐揚げ弁当 : ${allocatedMap["[KRG]"] ?? 0} 個
・ 焼き魚弁当 : ${allocatedMap["[YKS]"] ?? 0} 個
▼ 注文内容の変更・キャンセルはこちらから
${prefilledUrl}
なお、${time} 現在の注文可能数は以下のとおりとなっております。
${formDescriptionText.replace("【現在の残数状況】", "")}
`
break;
case "partial":
title = "【重要】お弁当の注文数を一部修正しました"
body = `${user} 様
ご注文ありがとうございます。
恐れ入りますが、残数が不足のため、以下のとおり注文内容を変更して受け付けしました。
・ ハンバーグ弁当: ${allocatedMap["[HBG]"] ?? 0} 個 ${rejectedMap["[HBG]"] ? `(※在庫不足のため ${allocatedMap["[HBG]"] ?? 0} 個に修正)` : ""}
・ 唐揚げ弁当 : ${allocatedMap["[KRG]"] ?? 0} 個 ${rejectedMap["[KRG]"] ? `(※在庫不足のため ${allocatedMap["[KRG]"] ?? 0} 個に修正)` : ""}
・ 焼き魚弁当 : ${allocatedMap["[YKS]"] ?? 0} 個 ${rejectedMap["[YKS]"] ? `(※在庫不足のため ${allocatedMap["[YKS]"] ?? 0} 個に修正)` : ""}
▼ 注文内容の変更・キャンセルはこちらから
${prefilledUrl}
なお、${time} 現在の注文可能数は以下のとおりとなっております。
${formDescriptionText.replace("【現在の残数状況】", "")}
`
break;
case "rejected":
title = "【重要】お弁当のご注文を承れませんでした";
body = `${user} 様
ご注文ありがとうございます。
大変申し訳ございませんが、先に他のお客様のご注文が確定し、
ご希望いただいたお弁当の在庫が不足したため、今回はご注文を承ることができませんでした。
${time} 現在の注文可能数は以下のとおりとなっております。
${formDescriptionText.replace("【現在の残数状況】", "")}
在庫状況をご確認のうえ、必要でしたらこちらから再度注文をお願いします。
${prefilledUrl}
`
break;
case "soldout":
title = "【完売御礼】お弁当はすべて売り切れとなりました";
body = `${user} 様
ご注文ありがとうございます。
大変申し訳ございませんが、先に他のお客様のご注文が確定し、
すべてのお弁当が【完売】となりました。そのため、
◆今回はご注文を承ることができませんでした◆
またのご利用をお待ちしております。`
break;
}
GmailApp.sendEmail(mail, title, body);
}



