0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GAS】Googleフォーム×在庫管理!注文数の変更・キャンセルに対応⑤排他制御(LockService)編

0
Last updated at Posted at 2026-06-26

今までの記事一覧
Googleフォーム×GASで実現する業務自動化シリーズ全記事まとめ目次

前提

この記事は、フォーム回答を保存しているスプレッドシート側のGASを前提にしています。

トリガーは以下を設定してください。

  • 実行する関数:onFormSubmit
  • イベントのソース:スプレッドシートから
  • イベントの種類:フォーム送信時

スプレッドシート構成

スプレッドシート「集計シート」をこの図のように設定しています。

image

  • B1セル:販売上限数
  • B2セル:「有効注文数」の合計(=SUM(F6:F)
  • B3セル:残数(=B1-B2
  • 6行目以下のA~D列:フォームから送信された回答
  • 6行目以下のE列:注文内容を変更・キャンセルした場合に、管理者が修正後の注文数を入力する列
  • 6行目以下のF列:在庫計算に使用する「有効注文数」(以下の関数を設定)
    • E列が空欄なら、フォームから送信された注文数(D列)を使用
    • E列に修正数が入力されていれば、その値を使用
=ARRAYFORMULA(IF(D6:D="","",IF(E6:E="",D6:D,E6:E)))
  • 6行目以下のG列:変更用の事前入力URLが入る

おさらい

お弁当注文システムシリーズ、前回は、変更フォームを使ってユーザー自らが注文の変更を送信する方法、それに伴い、あらかじめメールアドレスや氏名を入力済みの事前入力URLを作成して、注文時にユーザーにメール通知する機能を実装しました。

ここまで来たら、次に気になるのは同時に複数の注文や変更が入った場合どうなるの?ということですよね。
というわけで、今回はいよいよ排他制御として LockService を実装していこうと思います。
また、ロックがかかった状態でエラーになった場合に備え、try - catch - finally も同時につけようと思います。

やりたいこと

今までの処理に加え、

  • 短時間に複数のフォーム送信がされたとき、先に処理が走っているものの処理が終了するまで、次の処理を待機させたい。

LockServiceについて

LockServiceについては以前、こちらの記事でも詳しく説明しています。

LockServiceとは、簡単に説明すると、前の処理が完了するまで次の人はストップさせる機能です。
書き方は簡単。はじめに鍵をかけて最後に鍵を開けるだけです。

function onFormSubmit(e) {
  //★ここで鍵をかける
  const lock = LockService.getScriptLock();
  lock.waitLock(30000);   //鍵をかける最大時間。1秒は1000
  
  //ここから先にやりたい処理を書く
  const formData = cleanFormData(e);
  //
  //
  //
  
  //★鍵を開ける
  lock.releaseLock();
}

上のコードは、前の処理が終わるまで最大30秒待つ、というものです。
これで順番どおり処理を進めることができます。

ここに入れる

排他制御を確実に機能させるため、メイン関数 function onFormSubmit(e) の先頭で鍵を取得し、後に書く try ブロックに入った直後で lock.waitLock(30000) を実行して鍵を掛けます。

また、今回はエラーが発生した際にも catch ブロックの中で「誰の送信データでエラーが起きたのか(メールアドレスやユーザー名)」をログに残したいため、それらの変数は try の外側であらかじめ宣言(let mail; など)しておくのがポイントです。これを行わないと、JavaScriptの仕様(変数のスコープ)の関係で、エラー時にログが残せなくなってしまいます。

サブ関数には鍵をかけなくても大丈夫。
メイン関数の onFormSubmit(e) のほうで制御してくれますから。

鍵を開けるのは、メイン関数の一番下にします。

try - catch - finally もセットで

LockService だけだと、もし処理の途中でエラーが発生してプログラムが強制終了してしまった場合、鍵が開けられないまま(ロックされたまま)になり、後続で順番待ちしていたユーザーの処理がすべてタイムアウトで全滅してしまう可能性があります。

前の処理がエラーになっても、そのエラーを安全にキャッチして、スムーズに次の処理にバトンタッチできるよう、try - catch - finally を使った例外処理を必ずセットで実装しましょう。

メイン処理全体を try - catch - finally で囲みます。
lock.waitLock()try の先頭で実行し、lock.releaseLock()finally に配置します。
waitLock() 自体も例外を投げる可能性があるため、try の外ではなく try の中で実行しています。

try{ ... }:この{ ... }の中に、在庫計算やシート書き込みなど、メインの処理を丸ごと書きます。
catch{ ... }:この{ ... }の中に、万が一エラーが発生した場合の処理を書きます(管理者への通知やログの記録)。
finally{ ... }:この{ ... }の中に、エラーが起きても起きなくても、最後に必ず実行したい処理を書きます。ここに lock.releaseLock()(鍵を開ける)を配置することで、何があっても絶対に次の人に鍵を渡すことができます。

GASコード

排他制御/例外処理を入れたGASコード例です。

function onFormSubmit(e) {
  //★★LockServiceを呼び出す(finallyで鍵を開けるために、tryの上に書く)
  const lock = LockService.getScriptLock();
  Utilities.sleep(10000);

  Logger.log("開始");

  //★後のcatchでユーザー情報を使うため、tryの外側に変数を宣言
  let mail = "";
  let user = "";
  let num = 0;

  try {      //★★エラーが起きたときにスキップする処理(メイン処理)
    // ★ここで鍵をかける(最大30秒待機)
    lock.waitLock(30000);

    const ss = SpreadsheetApp.getActiveSpreadsheet();

    const sheet = e.range.getSheet();  //フォーム回答が入力されたシート
    const row = e.range.getRow();  //フォーム回答が入力された行
    const column = e.values.length;  // フォーム回答シートに書き込まれた列
    const formData = sheet.getRange(row, 1, 1, column).getValues();

    mail = e.values[1];   //フォーム送信データからメールアドレスを取得
    user = e.values[2];   //フォーム送信データからユーザー名を取得
    num = Number(e.values[3]);   //フォーム送信データから注文数を取得  

    // 紐づいているフォームを取得(管理用ID)
    const form = FormApp.openById("1#########"); // 注文フォーム
    const corForm = FormApp.openById("1**********"); // 変更フォーム

    /// 変更フォームの事前入力URLを組み立てる ///////
    // 変更フォームの「メールアドレス項目」と「氏名項目」のオブジェクトを取得
    const items = corForm.getItems();
    const mailItem = items[0].asTextItem(); // 1番目がメールアドレスの場合
    const nameItem = items[1].asTextItem(); // 2番目が氏名の場合

    let response = corForm.createResponse();

    response = response.withItemResponse(mailItem.createResponse(mail)); // メールをセット
    response = response.withItemResponse(nameItem.createResponse(user)); // 氏名をセット

    // 最後にURLを取得
    const prefilledUrl = response.toPrefilledUrl();


    //集計シートの最終行
    const sheet_summary = ss.getSheetByName("集計シート");
    const summaryLastRow = sheet_summary
      .getRange(sheet_summary.getMaxRows(), 1)
      .getNextDataCell(SpreadsheetApp.Direction.UP)
      .getRow();        //A列の最終行を取得

    const mailDataList = sheet_summary.getRange(5, 2, summaryLastRow - 4, 1).getValues().flat();

    let currentRemaining = Number(sheet_summary.getRange("B3").getValue());

    const sheetName = sheet.getName();

    let editRow;
    const lastHitIndex = mailDataList.lastIndexOf(mail);

    if (sheetName === "フォームの回答1") {        // 注文フォーム(新規)
      if (currentRemaining - num < 0) {     // 在庫がマイナスになった場合はお断りメール
        rejectionMail(mail, user, currentRemaining, prefilledUrl);     // function rejectionMail
        return;
      } else {
        editRow = summaryLastRow + 1;

      }

    } else if (sheetName === "フォームの回答2") {     // 変更フォーム(修正)
      // 安全対策:もし過去の注文(メールアドレス)がシート内に見つからなければお断りメール(2)
      if (lastHitIndex === -1) {
        rejectionCorrectionMail(mail, user, form);      //function rejectionCorrectionMail
        return;
      }

      // 先にアドレスがある行を特定する
      editRow = lastHitIndex + 5;

      // 特定した行から、前回の注文数(preNum)を安全に取得(F列=6列目の場合)
      const preNum = Number(sheet_summary.getRange(editRow, 6).getValue());

      // preNumが確定したので、ここで在庫の計算を行う
      if (currentRemaining - num + preNum < 0) {   // 在庫がマイナスになった場合はお断りメール
        rejectionMail(mail, user, currentRemaining + preNum, prefilledUrl);          // function rejectionMail
        return;
      }
    }
    sheet_summary.getRange(editRow, 1, 1, column).setValues(formData);
    sheet_summary.getRange(editRow, 7).setValue(prefilledUrl);
    sheet_summary.getRange(editRow, 5).clearContent();     //管理者が入力したデータをクリアする
    SpreadsheetApp.flush();    //今書き込んだ内容をシートに反映させる

    // 最新の在庫数をシートから再取得
    currentRemaining = Number(sheet_summary.getRange("B3").getValue());

    // もし残数が0以下になったら、フォームを自動で締め切る
    if (currentRemaining === 0) {
      form.setCustomClosedFormMessage("本日の注文受付は【完売】のため終了いたしました。またのご利用をお待ちしております!");
      form.setAcceptingResponses(false); // 新規受付を停止
      //変更フォームにだけ、残数を表示
      corForm.setDescription("【現在の残数: " + currentRemaining + " 個】"
        + "\n" + "元々のご自身の注文数にこの数を足したものが、注文可能数です。");


    } else {  //残数がまだある場合
      //フォームが受付停止になっていたら再開
      if (form.isAcceptingResponses() === false) {
        form.setAcceptingResponses(true);
      }
      //フォーム全体の「説明欄」を書き換える場合
      form.setDescription("【現在の残数: " + currentRemaining + " 個】");
      corForm.setDescription("【現在の残数: " + currentRemaining + " 個】"
        + "\n" + "元々のご自身の注文数にこの数を足したものが、注文可能数です。");

      //特定の質問(注文数)の「プルダウン選択肢」を書き換える
      const items = form.getItems();
      for (const item of items) {
        if (item.getTitle() === "注文数を選択してください") {

          // プルダウン(asListItem) に型変換
          const listItem = item.asListItem();

          // 1 から currentRemaining までの「文字列の配列」を作る
          //キャンセルの場合は0を入れてもらうため、選択肢を0からに変更
          const choices = [];
          for (let i = 0; i <= currentRemaining; i++) {
            choices.push(String(i)); // "1", "2", "3"... と文字列で入れていく
          }

          // プルダウンの選択肢を最新在庫に合わせて書き換え
          listItem.setChoiceValues(choices);

          // ヘルプテキスト(説明文)を更新
          listItem.setHelpText("※現在の残数は「あと " + currentRemaining + " 個」です。");
          break;
        }
      }
    }
    //サブ関数 acceptMail を呼び出し、受付メール送信
    acceptMail(mail, user, num, prefilledUrl);

  } catch (err) {//★★上でエラーが起こったときに行う処理(自分宛にエラー通知メールを送るとかログを残すなど)    
    Logger.log(`以下の内容でエラーが起こりました。
  mail:${mail}
  user:${user}
  注文数:${num}
  エラー内容:${err.stack}`);

  } finally {       //★★エラーが起きても起きなくてもやる処理
    //★ロックされている場合は鍵を開ける(エラーが起きても起きなくても鍵を開けないといけない)
    if (lock.hasLock()) {
      lock.releaseLock();
    }
  }
}

コードの中で、お断りメール(サブ関数)を送った後に return をしてますが、関数が完全に終了する前に必ず finally が実行されるため、鍵は必ず解放されます。

動作確認

下図のシートのとおり、在庫の残数が3個の状態で、すでに4個注文済みの「第1走者」さんが、変更フォームで3個注文、間髪入れずに「第2走者」さんが注文フォームで3個注文を送信してみます。「第1走者」さんは注文数7個に修正され、「第2走者」さんはその時点で在庫が0になるためお断りメールが作成されるはずです。

image

image

実行してみると、シートでは「第1走者」さんの注文数が7に変わり、

image

「第2走者」さんの注文は受理されずにお断りメールが作成されました。

image

わざとエラーを発生させたときもこのようにエラーログが出ます。

以下の内容でエラーが起こりました。
  mail:test1@mail.com
  user:第1走者
  注文数:5
  エラー内容:ReferenceError: prefilledUrl is not defined
  発生場所(行):ReferenceError: prefilledUrl is not defined
    at rejectionMail (コード:192:6)
    at onFormSubmit0 (無題:92:9)
    at __GS_INTERNAL_top_function_call__.gs:1:8

残念ながら、try の中で起こったエラーについては実行結果の画面では、エラーにも関わらず「完了」となってしまいます。
今回はログにエラー内容を出すようにしていますが、ログを開かないとエラーになっていることが分からないため、実務では管理者宛にメールする運用にしたほうがいいかもしれません。

image

まとめ

今回は、フォームが同時多発的に送信された場合でも、こんがらがったりせず順番に処理を進める LockService と、途中でエラーになっても確実に鍵を返すための try - catch - finally を実装しました。
LockService はフォーム送信順を保証するものではありませんが、同時実行による在庫計算の競合やシートの上書きを防ぐことができます。詳しくはこちらの記事でも検証しています。
LockServiceの順番待ち エラー・タイムアウト・鍵の返し忘れで暴かれた鍵取りゲームの実態

実行順そのものは保証されませんが、最終行の取得競合によるデータ上書きなどの事故は防ぐことができます。

これで「たまたま同時に注文が来たせいで在庫がマイナスになった」「他の人のデータを上書きしてしまった」といった事故はかなり防げるようになりました。

地味な機能ですが、こういう裏方の仕組みこそ本番運用ではとても大切です。お弁当注文システムも少しずつ丈夫になってきました。

次回はいよいよお弁当の種類を増やしてみようと思います。ここまでは「お弁当は1種類」という前提で作ってきましたが、実際の注文ではそうもいきません。複数のお弁当を扱えるようにして、もう少し現実的なシステムに育てていきます。

今回の成果コード

メイン関数

function onFormSubmit(e) {
  //★★LockServiceを呼び出す(finallyで鍵を開けるために、tryの上に書く)
  const lock = LockService.getScriptLock();

  //★後のcatchでユーザー情報を使うため、tryの外側に変数を宣言
  let mail = "";
  let user = "";
  let num = 0;

  try {      //★★エラーが起きたときにスキップする処理(メイン処理)
    // ★ここで鍵をかける(最大30秒待機)
    lock.waitLock(30000);

    const ss = SpreadsheetApp.getActiveSpreadsheet();

    const sheet = e.range.getSheet();  //フォーム回答が入力されたシート
    const row = e.range.getRow();  //フォーム回答が入力された行
    const column = e.values.length;  // フォーム回答シートに書き込まれた列
    const formData = sheet.getRange(row, 1, 1, column).getValues();

    mail = e.values[1];   //フォーム送信データからメールアドレスを取得
    user = e.values[2];   //フォーム送信データからユーザー名を取得
    num = Number(e.values[3]);   //フォーム送信データから注文数を取得  

    // 紐づいているフォームを取得(管理用ID)
    const form = FormApp.openById("1#########"); // 注文フォーム
    const corForm = FormApp.openById("1**********"); // 変更フォーム

    /// 変更フォームの事前入力URLを組み立てる ///////
    // 変更フォームの「メールアドレス項目」と「氏名項目」のオブジェクトを取得
    const items = corForm.getItems();
    const mailItem = items[0].asTextItem(); // 1番目がメールアドレスの場合
    const nameItem = items[1].asTextItem(); // 2番目が氏名の場合

    let response = corForm.createResponse();

    response = response.withItemResponse(mailItem.createResponse(mail)); // メールをセット
    response = response.withItemResponse(nameItem.createResponse(user)); // 氏名をセット

    // 最後にURLを取得
    const prefilledUrl = response.toPrefilledUrl();


    //集計シートの最終行
    const sheet_summary = ss.getSheetByName("集計シート");
    const summaryLastRow = sheet_summary
      .getRange(sheet_summary.getMaxRows(), 1)
      .getNextDataCell(SpreadsheetApp.Direction.UP)
      .getRow();        //A列の最終行を取得

    const mailDataList = sheet_summary.getRange(5, 2, summaryLastRow - 4, 1).getValues().flat();

    let currentRemaining = Number(sheet_summary.getRange("B3").getValue());

    const sheetName = sheet.getName();

    let editRow;
    const lastHitIndex = mailDataList.lastIndexOf(mail);

    if (sheetName === "フォームの回答1") {        // 注文フォーム(新規)
      if (currentRemaining - num < 0) {     // 在庫がマイナスになった場合はお断りメール
        rejectionMail(mail, user, currentRemaining, prefilledUrl);     // function rejectionMail
        return;
      } else {
        editRow = summaryLastRow + 1;

      }

    } else if (sheetName === "フォームの回答2") {     // 変更フォーム(修正)
      // 安全対策:もし過去の注文(メールアドレス)がシート内に見つからなければお断りメール(2)
      if (lastHitIndex === -1) {
        rejectionCorrectionMail(mail, user, form);      //function rejectionCorrectionMail
        return;
      }

      // 先にアドレスがある行を特定する
      editRow = lastHitIndex + 5;

      // 特定した行から、前回の注文数(preNum)を安全に取得(F列=6列目の場合)
      const preNum = Number(sheet_summary.getRange(editRow, 6).getValue());

      // preNumが確定したので、ここで在庫の計算を行う
      if (currentRemaining - num + preNum < 0) {   // 在庫がマイナスになった場合はお断りメール
        rejectionMail(mail, user, currentRemaining + preNum, prefilledUrl);          // function rejectionMail
        return;
      }
    }
    sheet_summary.getRange(editRow, 1, 1, column).setValues(formData);
    sheet_summary.getRange(editRow, 7).setValue(prefilledUrl);
    sheet_summary.getRange(editRow, 5).clearContent();     //管理者が入力したデータをクリアする
    SpreadsheetApp.flush();    //今書き込んだ内容をシートに反映させる

    // 最新の在庫数をシートから再取得
    currentRemaining = Number(sheet_summary.getRange("B3").getValue());

    // もし残数が0以下になったら、フォームを自動で締め切る
    if (currentRemaining === 0) {
      form.setCustomClosedFormMessage("本日の注文受付は【完売】のため終了いたしました。またのご利用をお待ちしております!");
      form.setAcceptingResponses(false); // 新規受付を停止
      //変更フォームにだけ、残数を表示
      corForm.setDescription("【現在の残数: " + currentRemaining + " 個】"
        + "\n" + "元々のご自身の注文数にこの数を足したものが、注文可能数です。");


    } else {  //残数がまだある場合
      //フォームが受付停止になっていたら再開
      if (form.isAcceptingResponses() === false) {
        form.setAcceptingResponses(true);
      }
      //フォーム全体の「説明欄」を書き換える場合
      form.setDescription("【現在の残数: " + currentRemaining + " 個】");
      corForm.setDescription("【現在の残数: " + currentRemaining + " 個】"
        + "\n" + "元々のご自身の注文数にこの数を足したものが、注文可能数です。");

      //特定の質問(注文数)の「プルダウン選択肢」を書き換える
      const items = form.getItems();
      for (const item of items) {
        if (item.getTitle() === "注文数を選択してください") {

          // プルダウン(asListItem) に型変換
          const listItem = item.asListItem();

          // 1 から currentRemaining までの「文字列の配列」を作る
          //キャンセルの場合は0を入れてもらうため、選択肢を0からに変更
          const choices = [];
          for (let i = 0; i <= currentRemaining; i++) {
            choices.push(String(i)); // "1", "2", "3"... と文字列で入れていく
          }

          // プルダウンの選択肢を最新在庫に合わせて書き換え
          listItem.setChoiceValues(choices);

          // ヘルプテキスト(説明文)を更新
          listItem.setHelpText("※現在の残数は「あと " + currentRemaining + " 個」です。");
          break;
        }
      }
    }
    //サブ関数 acceptMail を呼び出し、受付メール送信
    acceptMail(mail, user, num, prefilledUrl);

  } catch (err) {//★★上でエラーが起こったときに行う処理(自分宛にエラー通知メールを送るとかログを残すなど)    
    Logger.log(`以下の内容でエラーが起こりました。
  mail:${mail}
  user:${user}
  注文数:${num}
  エラー内容:${err.stack}`);

  } finally {       //★★エラーが起きても起きなくてもやる処理
    //★ロックされている場合は鍵を開ける(エラーが起きても起きなくても鍵を開けないといけない)
    if (lock.hasLock()) {
      lock.releaseLock();
    }
  }
}

サブ関数(メール送信)

///////////////////////////
//お断りメール(1)
//////////////////////////
function rejectionMail(mail, user, num, prefilledUrl) {

  const title = "【重要】お弁当のご注文を承れませんでした"

  let body;
  if (num <= 0) {
    body = `
  ${user} 様

  ご注文ありがとうございます。
  大変申し訳ございませんが、先に他のお客様のご注文が確定し、
  残数が0になったため、${user}様の注文を承ることができませんでした。

  またのご利用をお待ちしております。
`;
  } else {
    const time = Utilities.formatDate(new Date(), "JST", "yyyy/MM/dd HH:mm");
    body = `
  ${user} 様

  ご注文ありがとうございます。
  大変申し訳ございませんが、先に他のお客様のご注文が確定したため
  ${user}様の注文を承ることができませんでした。
  恐れ入りますが再度以下のリンクから注文をお願いします。

   ${prefilledUrl}

  なお、${time} 現在の注文可能数は【 ${num} 個】となっております。  
  `;
  }
  GmailApp.sendEmail(mail, title, body);
}

///////////////////////////
//お断りメール(2) 新規なのに変更フォームで送った場合
//////////////////////////
function rejectionCorrectionMail(mail, user, form) {

  /// ★★注文フォームの事前入力URLを組み立てる ///////
  const items = form.getItems();
  const mailItem = items[0].asTextItem(); // 1番目がメールアドレスの場合
  const nameItem = items[1].asTextItem(); // 2番目が氏名の場合

  let response = form.createResponse();

  response = response.withItemResponse(mailItem.createResponse(mail)); // メールをセット
  response = response.withItemResponse(nameItem.createResponse(user)); // 名前をセット

  // 最後にURLを取得
  const prefilledUrl = response.toPrefilledUrl();

  const title = "【重要】お弁当のご注文を承れませんでした"
  let body = `
  ${user} 様
  ご注文ありがとうございます。
  変更フォームより送信いただきましたが、お客様の「事前の注文データ」が見つかりませんでした。

  恐れ入りますが、ご注文がお済みでない場合は、
  以下の「注文専用フォーム」から新規のご注文をお願いいたします。

  ▼ 注文専用フォームはこちら
   ${prefilledUrl}

  ※すでに注文済みで、メールアドレスを変更された場合などは、お手数ですが管理者まで直接ご連絡ください。
`;

  GmailApp.sendEmail(mail, title, body);
}
///////////////////////////
//受付メール
///////////////////////////
function acceptMail(mail, user, num, prefilledUrl){
  const title = "お弁当の注文を受け付けました"
  let body = `
  ${user} 様
  ご注文ありがとうございます。
  以下のとおり、お弁当の注文を承りました。

  注文数:${num} 個

  注文内容の変更・キャンセルは、以下のURLから変更内容を送信してください。
   ${prefilledUrl}
  `;
  GmailApp.sendEmail(mail, title, body);
}

image.png

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?