3 小時實作課|Google 試算表 × 表單 × Apps Script

3 小時 GAS 線上同步研習

今天先完成一套可運作的飲料訂購行政流程:建立表單、產生練習資料、整理明細、統計品項,最後產生可貼到群組的通知文字。

流程跑通後,再用一次欄位改造實驗看懂固定欄位限制,理解「通用行政整理工具:讓不同表單共用相同的資料整理流程。」

課堂必達 表單、模擬資料、明細、統計、群組通知文字。
線上保底 操作卡住時,可用固定版完整程式碼回到進度。
適合對象 會使用 Google 表單與試算表,剛開始接觸 GAS 的教師與行政人員。
課後延伸 社團報名、研習報名、設備借用、成果收件。

1今天會完成什麼

這堂課的主線很明確:先完成飲料訂購固定版,讓表單資料能被整理、統計並產生通知;再用一次簡單改造,看懂為什麼不能永遠依賴 row[1]row[2] 這種固定欄位順序。

課堂必達成果

  • 建立飲料訂購 Google 表單。
  • 親自送出一筆表單資料。
  • 自動產生 15 筆模擬訂購資料。
  • 建立行政工作台、整理明細、統計品項數量。
  • 產生群組通知文字,完成第一套行政流程。
  • 完成一次欄位改造實驗。

課後延伸成果

  • 把流程改成社團報名、研習報名、設備借用、成果收件或值勤調查。
  • 完整練習 GAS 07、GAS 08、GAS 09 的通用整理、統計與通知。
  • 使用完整通用行政整理工具或雙層整合版本做系統化擴充。
課堂中先做得出來,再看懂為什麼要通用化;完整通用行政整理工具保留為課後進階練習。

2一份表單資料如何流動

行政工作常見的麻煩,通常是資料散在不同地方、格式不一致、狀態不清楚。今天先把一份飲料訂購資料固定成清楚流程:收件、整理、統計、通知。

收件用表單統一入口
整理把回覆變成可讀清單
分類找出主要判斷欄位
統計加總數量或狀態
通知輸出可複製文字
追蹤知道誰完成、誰未完成
改造換欄位就能套用到其他流程
GAS 的價值不是讓人多做表格,而是把重複整理、重複統計、重複通知的部分固定下來。

3為什麼先做固定版,再看通用版

固定版讓資料流看得見:第幾欄就是哪個資料。當表單欄位名稱或順序被改動時,固定版的限制就會出現。通用行政整理工具則用欄位對照,把「姓名」「主要分類」「數量」「備註」這些角色和實際表單欄位連起來。

第一層:飲料訂購固定版

Google 試算表從空白試算表開始
建立飲料表單一鍵建立固定欄位表單
表單回應送出測試資料
訂購明細把回覆整理成清單
品項統計加總各品項杯數
群組通知文字產生可貼上的通知

第二層:通用行政整理工具

固定欄位限制看懂 row[1] 寫法的風險
欄位對照示範角色如何對應表單欄位
簡單改造課堂只調整一個欄位或角色
通用整理課後再完整練習
通用統計與通知進階應用保留完整程式碼
行政情境延伸課後改成自己的流程

4線上研習操作提醒

建議開啟的畫面

視訊會議

觀看講解、聽指示、必要時分享畫面。

課程講義

依照 GAS 01 到 GAS 05 分段複製程式碼。

Google 試算表

建立表單、查看工作表、確認整理結果。

Apps Script 編輯器

貼上程式碼、儲存、選擇函式並執行。

建議操作方式

  • 盡量使用桌上型電腦或筆記型電腦,建議使用 Chrome。
  • 可將視訊會議與操作畫面左右分割。
  • 執行 Apps Script 後,回到試算表重新整理。
  • 不要一次貼上全部完整程式碼。
  • 不要任意更改工作表名稱。
  • 看見 Google 權限畫面時,依指示完成授權。
  • 發生錯誤時,先保留錯誤訊息。
  • 不要立即刪除全部程式碼。
  • 跟不上時先停在目前檢查點,不要持續亂試。

線上提問格式

  • 目前進行到 GAS 幾。
  • 執行的函式名稱。
  • 完整錯誤訊息。
  • 是否已完成第一次授權。
  • 目前有哪些工作表。
  • 建議附上截圖,包含上方分頁名稱與錯誤訊息。

開始前請確認

第一次執行 GAS 可能會出現授權流程。請先保留授權畫面,不要關掉分頁;下一段會依照可能出現的畫面逐步處理。

5第一次執行 Apps Script 的授權

Apps Script 第一次執行時,常會要求授權。畫面可能因 Google 帳號類型、瀏覽器狀態或學校網域管理政策而不同,不保證每個人看到的文字與按鈕完全相同。

可能流程

  1. 按下執行。
  2. 出現需要授權提示。
  3. 點選「審查權限」。
  4. 選擇這次練習要使用的 Google 帳號。
  5. 若出現「Google 尚未驗證這個應用程式」,點選「進階」。
  6. 選擇前往自己建立的 Apps Script 專案。
  7. 按下允許。

如果授權卡住

  • 確認這是你在自己試算表中建立的 Apps Script 專案。
  • 學校 Workspace 帳號可能禁止某些授權或外部應用程式。
  • 若受到學校帳號限制,可改用個人 Google 帳號練習。
  • 請先記下畫面文字或截圖,再提出問題;不要立即刪除全部程式碼。

6專案管理的五個小問題

在寫 GAS 前,先問清楚五個問題,系統會比較穩。這些問題可以幫助判斷表單要收什麼、程式要整理什麼、最後要輸出什麼。

目的

為什麼要建立這個流程?

使用者

誰填寫、誰整理、誰接收結果?

資料

表單要收哪些欄位?哪些欄位是必要的?

規則

期限、資格、數量或審核條件是什麼?

輸出

最後需要名冊、統計、通知文字還是文件?

7飲料訂購固定版:先完成可用工具

固定版會直接使用飲料訂購表單的欄位順序。這種做法適合初學,因為可以清楚看見「表單回應第幾欄」如何被整理到「訂購明細」。這一層先不追求表單任意變動,而是先看懂資料如何從表單回應流向明細、統計與通知。

工作表

訂購明細、品項統計、通知文字、系統設定。

固定欄位

row[1] 是姓名,row[2] 是飲料品項,row[5] 是數量。

可用成果

先把表單回應整理成能檢查、能統計、能通知的結果。

線上研習建議先使用「產生 15 筆模擬訂購資料」,確保每個人都有資料可以整理。等流程跑通後,再回頭測試實際填表。

8飲料訂購表單欄位

GAS 01 會自動建立飲料訂購表單。課堂前半段先把這些欄位當作固定順序處理,後面再用欄位對照示範它們如何對應成欄位角色。

依帳號語系不同,Google 介面可能顯示「表單回應 1」、「表單回覆 1」或「Form Responses 1」,因此程式不應只依賴工作表名稱判斷。本課統一稱為「表單回應工作表」,程式會以第一列欄位標題辨識。
欄位順序 表單欄位 欄位類型 固定版讀取方式
1時間戳記表單自動產生row[0]
2姓名簡答row[1]
3飲料品項下拉選單row[2]
4甜度單選,必填row[3]
5冰塊單選,必填row[4]
6數量下拉選單row[5]
7備註段落row[6]

93 小時操作進度

0:00–0:15
課程說明、環境確認、行政流概念
確認今天的作品、視窗配置、授權流程與資料流概念。
理解今天要完成的作品
0:15–0:35
GAS 01:建立飲料訂購表單
從試算表執行 GAS,自動建立可填寫的 Google 表單。
出現可填寫的 Google 表單
0:35–0:50
親填 1 筆資料+產生 15 筆模擬資料
先自己送出一筆表單,看見資料進入試算表;再用 GAS 產生足夠的練習資料。
建立足夠的練習資料
0:50–1:10
GAS 02:建立飲料訂購工作台
建立訂購明細、品項統計、通知文字與系統設定。
出現所需工作表
1:10–1:35
GAS 03:整理訂購明細
用固定欄位順序把表單回應整理成訂購名單。
表單回應轉為乾淨明細
1:35–1:55
GAS 04:統計品項數量
依飲料品項加總杯數。
產生統計結果
1:55–2:10
GAS 05:產生群組通知文字
把統計與明細整理成可貼到群組的文字。
完成第一套行政流程
2:10–2:20
休息與問題排除
依檢查點確認表單、回覆表、工作台、明細、統計與通知文字;卡住者可使用課堂保底版回到進度。
讓掉隊學員重新加入
2:20–2:35
交換兩欄,觀察固定版限制
先備份試算表,再到表單回應工作表交換「飲料品項」與「甜度」兩欄,執行固定版整理後觀察欄位意義錯置,最後復原欄位順序。
看見固定欄位程式的問題
2:35–2:50
講師示範欄位對照與通用版概念
看懂欄位名稱、欄位索引與欄位角色,示範欄位對照表如何讓整理流程穩定。
理解欄位角色與欄位名稱
2:50–3:00
學員完成一項簡單情境改造
以「飲料訂購」改成「社團報名」為例,只完成一項欄位名稱或角色調整。
能帶回修改成自己的行政流程

10GAS 01:建立飲料訂購表單

這一段會完成什麼

從試算表開啟 Apps Script,執行程式後自動建立 Google 表單,並把表單回應連結到目前這份試算表。

0:15–0:35
功能:建立表單 完成後:可填寫的飲料訂購表單 核心函式:建立飲料訂購表單()
請保留前一段程式碼,將本段程式貼在程式碼.gs 最下方,不要覆蓋前面的函式。
操作完成後會看到什麼

畫面會跳出飲料訂購表單的填寫網址。這份表單是前半段固定版的收件入口,欄位包含姓名、飲料品項、甜度、冰塊、數量與備註;甜度與冰塊也維持必填,讓明細資料完整。

如果「系統設定」已有可開啟的表單 ID、編輯網址或填寫網址,程式會先詢問是否仍要建立新表單。選擇「否」就會停止,不會重複建立;新表單建立後會保存表單 ID、編輯網址、填寫網址與回應試算表 ID。

程式碼
GAS 程式碼
function 建立飲料訂購表單() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();

  let settingSheet = ss.getSheetByName('系統設定');
  if (!settingSheet) {
    settingSheet = ss.insertSheet('系統設定');
  }
  if (settingSheet.getLastRow() === 0) {
    settingSheet.getRange(1, 1, 1, 2).setValues([['key', 'value']]);
  }

  const settings = {};
  const settingRows = {};
  settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
    const key = String(row[0] || '').trim();
    const value = row[1];
    if (key) {
      settingRows[key] = index + 2;
      if (value !== '') settings[key] = value;
    }
  });

  let existingForm = null;
  if (settings['表單ID']) {
    try {
      existingForm = FormApp.openById(String(settings['表單ID']).trim());
    } catch (error) {
      existingForm = null;
    }
  }
  if (!existingForm && settings['表單編輯網址']) {
    try {
      existingForm = FormApp.openByUrl(String(settings['表單編輯網址']).trim());
    } catch (error) {
      existingForm = null;
    }
  }
  if (!existingForm && settings['表單填寫網址']) {
    try {
      existingForm = FormApp.openByUrl(String(settings['表單填寫網址']).trim());
    } catch (error) {
      existingForm = null;
    }
  }

  if (existingForm) {
    const result = ui.alert(
      '已存在飲料訂購表單',
      '系統設定中已經有有效的表單資料。是否仍要建立一份新的表單?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) {
      ui.alert('已取消建立新表單。');
      return;
    }
  }

  const form = FormApp.create('飲料訂購表單');
  form.setDescription('請填寫飲料訂購資料。這份表單用來練習行政收件:收件、整理、統計與通知。');

  form.addTextItem().setTitle('姓名').setRequired(true);
  form.addListItem().setTitle('飲料品項').setChoiceValues(['紅茶', '綠茶', '奶茶', '拿鐵']).setRequired(true);
  form.addMultipleChoiceItem().setTitle('甜度').setChoiceValues(['正常糖', '半糖', '微糖', '無糖']).setRequired(true);
  form.addMultipleChoiceItem().setTitle('冰塊').setChoiceValues(['正常冰', '少冰', '微冰', '去冰']).setRequired(true);
  form.addListItem().setTitle('數量').setChoiceValues(['1', '2', '3', '4', '5']).setRequired(true);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);

  form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());

  const updates = {
    '流程名稱': settings['流程名稱'] || '飲料訂購行政流',
    '通知模式': settings['通知模式'] || '群組通知',
    '通知標題': settings['通知標題'] || '飲料訂購行政流',
    '店家名稱': settings['店家名稱'] || '今天飲料店',
    '收單時間': settings['收單時間'] || '今天 11:00',
    '截止時間': settings['截止時間'] || settings['收單時間'] || '今天 11:00',
    '表單ID': form.getId(),
    '表單編輯網址': form.getEditUrl(),
    '表單填寫網址': form.getPublishedUrl(),
    '回應試算表ID': ss.getId()
  };

  Object.entries(updates).forEach(([key, value]) => {
    if (settingRows[key]) {
      settingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      settingSheet.appendRow([key, value]);
    }
  });

  ui.alert('飲料訂購表單已建立完成!\n\n填寫網址:\n' + form.getPublishedUrl());
}
行政用途說明

這一段示範如何用 GAS 自動建立收件入口。先把飲料訂購做出來,後面再回頭看固定欄位寫法的限制。

線上課程檢查點

完成檢查

  • Apps Script 執行的是 建立飲料訂購表單
  • 已建立 Google 表單。
  • 畫面跳出表單填寫網址。
  • 可以開啟表單填寫網址。
  • 試算表中出現表單回應工作表。
  • 表單欄位名稱與課程內容一致:姓名、飲料品項、甜度、冰塊、數量、備註。
  • 「系統設定」工作表中有表單編輯網址與填寫網址。

若沒有成功

  1. 確認程式碼貼在 Apps Script 編輯器,不是在試算表儲存格。
  2. 確認已按下儲存。
  3. 確認上方函式選單選到 建立飲料訂購表單
  4. 確認第一次授權已完成。
  5. 確認是否出現錯誤訊息,先保留錯誤文字。
  6. 確認使用的是這次練習的正確 Google 帳號。
  7. 回到試算表重新整理後再查看工作表。
  8. 確認沒有把「系統設定」工作表名稱改掉。

回到課程的方法

  • 重新執行 建立飲料訂購表單
  • 若仍失敗,先使用講師範例表單或課堂保底版接回流程。
  • 若一直失敗,先看講義中的課堂保底版,或先觀看操作,不要停在授權畫面除錯太久。

11GAS 01-1:產生 15 筆模擬訂購資料

這一段會完成什麼

直接在表單回應工作表中建立 15 筆模擬訂購資料。線上研習時,即使沒有每位學員互相填表,也能立刻進行整理明細、統計品項與通知文字的演示。

0:35–0:50
功能:產生測試資料 完成後:表單回應工作表有 15 筆資料 核心函式:產生模擬訂購資料()
請保留前一段程式碼,將本段程式貼在程式碼.gs 最下方,不要覆蓋前面的函式。
本段包含產生模擬資料所需的輔助函式,請完整複製。
操作完成後會看到什麼

表單回應工作表會出現 15 筆包含姓名、飲料品項、甜度、冰塊、數量與備註的測試資料。後續可以直接執行「整理訂購明細」。

線上研習不要求每位學員手動填寫 2~3 筆;親自填一筆是為了看懂資料如何進入試算表,15 筆模擬資料則是為了讓統計結果更明顯。這段程式會先清除表單回應工作表第 2 列以下舊資料,再寫入 15 筆模擬資料,所以重複執行不會一直累加舊測試資料。

常見錯誤提醒
  • 如果找不到表單回應工作表,請先執行「建立飲料訂購表單」。
  • 如果表單剛建立但還沒有表單回應工作表,請先手動送出一筆表單,或讓程式建立同格式的模擬表單回應工作表。
  • 模擬資料涵蓋不同品項、甜度、冰量、數量與備註,方便後續觀察統計差異。
  • 模擬資料是練習用,正式使用前請清除測試資料。
程式碼
GAS 程式碼
function 產生模擬訂購資料() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let responseSheet = 尋找表單回應工作表();

  if (!responseSheet) {
    const excludedNames = new Set([
      '訂購明細',
      '品項統計',
      '通知文字',
      '系統設定',
      '整理明細',
      '分類統計',
      '欄位對照',
      '行政情境',
      '行政情境範例對照'
    ]);
    responseSheet = ss.getSheets().find(sheet => {
      if (excludedNames.has(sheet.getName()) || sheet.getLastColumn() < 1) return false;
      const candidateHeaders = sheet
        .getRange(1, 1, 1, sheet.getLastColumn())
        .getDisplayValues()[0]
        .map(value => String(value).trim());
      const name = sheet.getName();
      const hasCommonResponseName =
        name.startsWith('表單回應') ||
        name.startsWith('表單回覆') ||
        name.startsWith('Form Responses');
      return candidateHeaders.includes('時間戳記') || hasCommonResponseName;
    }) || null;
  }

  if (!responseSheet) {
    responseSheet = ss.insertSheet('表單回應 1');
  }

  const expectedHeaders = ['時間戳記', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'];
  const currentHeaders = responseSheet
    .getRange(1, 1, 1, expectedHeaders.length)
    .getDisplayValues()[0]
    .map(value => String(value).trim());
  const headerIsValid = expectedHeaders.every(
    (header, index) => currentHeaders[index] === header
  );

  if (!headerIsValid) {
    if (responseSheet.getLastRow() > 0) {
      throw new Error(
        '找到的表單回應工作表欄位與課程預期不一致,請確認第一列是否為:時間戳記、姓名、飲料品項、甜度、冰塊、數量、備註。'
      );
    }
    responseSheet.getRange(1, 1, 1, expectedHeaders.length).setValues([expectedHeaders]);
    responseSheet.getRange(1, 1, 1, expectedHeaders.length).setFontWeight('bold');
    responseSheet.setFrozenRows(1);
  }

  const mockRows = [
    ['王小明', '紅茶', '正常糖', '少冰', 1, ''],
    ['陳小華', '奶茶', '半糖', '去冰', 2, '加珍珠'],
    ['林品安', '綠茶', '微糖', '微冰', 1, ''],
    ['黃子晴', '拿鐵', '無糖', '少冰', 1, '不要吸管'],
    ['張育誠', '紅茶', '半糖', '正常冰', 3, ''],
    ['李佳蓉', '奶茶', '微糖', '少冰', 1, ''],
    ['吳柏翰', '綠茶', '無糖', '去冰', 2, ''],
    ['劉怡君', '拿鐵', '正常糖', '正常冰', 1, '加冰塊'],
    ['蔡承恩', '紅茶', '微糖', '去冰', 1, ''],
    ['楊雅婷', '奶茶', '半糖', '微冰', 2, '少甜一點'],
    ['周冠宇', '綠茶', '正常糖', '少冰', 1, ''],
    ['鄭羽庭', '拿鐵', '半糖', '去冰', 1, ''],
    ['許哲維', '紅茶', '無糖', '微冰', 2, ''],
    ['洪詩涵', '奶茶', '正常糖', '少冰', 1, '加椰果'],
    ['郭家豪', '綠茶', '微糖', '正常冰', 3, '']
  ];

  const baseTime = new Date();
  const output = mockRows.map((row, index) => {
    const timestamp = new Date(baseTime.getTime() + index * 60 * 1000);
    return [timestamp, ...row];
  });

  const maxRows = responseSheet.getMaxRows();
  if (maxRows > 1) {
    responseSheet.getRange(2, 1, maxRows - 1, expectedHeaders.length).clearContent();
  }

  responseSheet.getRange(2, 1, output.length, expectedHeaders.length).setValues(output);
  responseSheet.autoResizeColumns(1, expectedHeaders.length);

  SpreadsheetApp.getUi().alert('已產生 15 筆模擬訂購資料,可以繼續執行整理訂購明細。');
}

function 尋找表單回應工作表() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const excludedNames = new Set([
    '訂購明細',
    '品項統計',
    '通知文字',
    '系統設定',
    '整理明細',
    '分類統計',
    '欄位對照',
    '行政情境',
    '行政情境範例對照'
  ]);
  return ss.getSheets().find(sheet => {
    if (excludedNames.has(sheet.getName())) return false;
    if (sheet.getLastColumn() < 1) return false;
    const headers = sheet
      .getRange(1, 1, 1, sheet.getLastColumn())
      .getDisplayValues()[0]
      .map(value => String(value).trim());
    return (
      headers.includes('時間戳記') &&
      headers.includes('姓名') &&
      headers.includes('飲料品項')
    );
  }) || null;
}

線上課程檢查點

完成檢查

  • 表單回應工作表第一列是時間戳記、姓名、飲料品項、甜度、冰塊、數量、備註。
  • 表單回應工作表中出現 15 筆以上資料。
  • 資料中看得到不同飲料、甜度、冰塊、數量與備註。
  • 欄位順序正確:時間戳記、姓名、飲料品項、甜度、冰塊、數量、備註。

若沒有成功

  1. 確認已按下儲存,且 Apps Script 沒有顯示語法錯誤。
  2. 確認已完整複製本段程式碼,內容應同時包含 產生模擬訂購資料尋找表單回應工作表
  3. 確認執行的是 產生模擬訂購資料
  4. 確認第一次授權已完成。
  5. 回到試算表重新整理,查看是否出現「表單回應」工作表。
  6. 確認表單回應工作表名稱是否正確。
  7. 確認欄位數量與模擬資料一致。
  8. 確認是否有程式錯誤訊息,先保留完整錯誤內容。
  9. 確認工作表名稱沒有被自行改成無法辨識的名稱。
  10. 如果要理解資料流,先親自送出一筆表單回應,再執行模擬資料。
  11. 若權限被阻擋,先改用個人 Google 帳號練習。

回到課程的方法

  • 重新執行 產生模擬訂購資料
  • 使用課堂保底版,或直接匯入講師提供的範例資料。
  • 重新按下本段複製按鈕,完整貼上後再執行;若程式已混亂,再改用課堂保底版。

12GAS 02:建立飲料訂購工作台

這一段會完成什麼

建立固定版會用到的四張工作表:訂購明細、品項統計、通知文字與系統設定。

0:50–1:10
功能:建立工作台 完成後:固定版四張工作表 核心函式:建立飲料訂購工作台()
請保留前一段程式碼,將本段程式貼在程式碼.gs 最下方,不要覆蓋前面的函式。
操作完成後會看到什麼

試算表會出現「訂購明細」「品項統計」「通知文字」「系統設定」。這是前半段固定版的工作台,先不加入欄位對照。

如果三張產出工作表已有資料,重建前會先詢問。繼續後只會清空訂購明細、品項統計與通知文字,不會刪除原始表單回應資料,也會保留系統設定中的既有欄位。

程式碼
GAS 程式碼
function 建立飲料訂購工作台() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  const existingSettings = {};
  const settingRows = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key) {
        settingRows[key] = index + 2;
        if (value !== '') existingSettings[key] = value;
      }
    });
  }

  const outputSheetNames = ['訂購明細', '品項統計', '通知文字'];
  const hasOutputData = outputSheetNames.some(name => {
    const sheet = ss.getSheetByName(name);
    return sheet && sheet.getLastRow() > 1;
  });
  if (hasOutputData) {
    const result = ui.alert(
      '重新建立工作台',
      '這個操作會清空訂購明細、品項統計與通知文字,但不會刪除原始表單回應資料。適合課程重新操作或重設使用。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }

  const sheetConfigs = [
    { name: '訂購明細', headers: ['序號', '填寫時間', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'] },
    { name: '品項統計', headers: ['飲料品項', '杯數'] },
    { name: '通知文字', headers: ['產生時間', '通知內容'] },
    { name: '系統設定', headers: ['key', 'value'] }
  ];

  sheetConfigs.forEach(config => {
    let sheet = ss.getSheetByName(config.name);
    if (!sheet) sheet = ss.insertSheet(config.name);
    if (config.name === '系統設定') {
      if (sheet.getLastRow() === 0) {
        sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
        sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
        sheet.setFrozenRows(1);
      }
    } else {
      sheet.clear();
      sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
      sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
      sheet.setFrozenRows(1);
    }
  });

  const updatedSettingSheet = ss.getSheetByName('系統設定');
  const defaultSettings = {
    '流程名稱': existingSettings['流程名稱'] || '飲料訂購行政流',
    '通知模式': existingSettings['通知模式'] || '群組通知',
    '通知標題': existingSettings['通知標題'] || '飲料訂購行政流',
    '店家名稱': existingSettings['店家名稱'] || '今天飲料店',
    '收單時間': existingSettings['收單時間'] || existingSettings['截止時間'] || '今天 11:00',
    '截止時間': existingSettings['截止時間'] || existingSettings['收單時間'] || '今天 11:00',
    '表單ID': existingSettings['表單ID'] || '',
    '表單編輯網址': existingSettings['表單編輯網址'] || '',
    '表單填寫網址': existingSettings['表單填寫網址'] || '',
    '回應試算表ID': existingSettings['回應試算表ID'] || ss.getId()
  };
  Object.entries(defaultSettings).forEach(([key, value]) => {
    if (settingRows[key]) {
      updatedSettingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      updatedSettingSheet.appendRow([key, value]);
    }
  });

  ss.getSheets().forEach(sheet => sheet.autoResizeColumns(1, Math.max(1, sheet.getLastColumn())));
  ui.alert('飲料訂購工作台建立完成!');
}

線上課程檢查點

完成檢查

  • 試算表下方出現「訂購明細」「品項統計」「通知文字」「系統設定」。
  • 「訂購明細」第一列有序號、填寫時間、姓名、飲料品項、甜度、冰塊、數量、備註。
  • 「品項統計」第一列有飲料品項與杯數。
  • 「通知文字」是本課使用的群組通知草稿工作表。
  • 工作表名稱正確,沒有多空白或自行改名。

若沒有成功

  1. 確認執行的是 建立飲料訂購工作台
  2. 確認已按下儲存,且沒有紅色語法錯誤。
  3. 確認第一次授權已完成。
  4. 回到試算表重新整理。
  5. 確認工作表名稱沒有被自行修改。

回到課程的方法

  • 重新執行 建立飲料訂購工作台
  • 若工作表被改亂,可再次執行此函式重建前半段工作台。
  • 仍無法完成時,使用課堂保底版後從 GAS 03 繼續。

13GAS 03:整理訂購明細

這一段會完成什麼

讀取表單回應資料,直接依照固定欄位順序整理成「訂購明細」。

1:10–1:35
功能:整理資料 完成後:訂購明細 核心函式:整理訂購明細()
請保留前一段程式碼,將本段程式貼在程式碼.gs 最下方,不要覆蓋前面的函式。
數量為空白、文字、0 或負數時,程式會指出表單回應的原始列號並停止整理,避免錯誤資料被默默當成 0。
程式碼
GAS 程式碼
function 整理訂購明細() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = 尋找表單回應工作表();
  const detailSheet = ss.getSheetByName('訂購明細');

  if (!responseSheet) {
    SpreadsheetApp.getUi().alert('找不到表單回應工作表,請先建立表單並送出測試資料。');
    return;
  }
  if (!detailSheet) {
    SpreadsheetApp.getUi().alert('找不到「訂購明細」,請先執行建立飲料訂購工作台。');
    return;
  }

  const data = responseSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('目前沒有表單回應資料,請先填寫幾筆測試資料。');
    return;
  }

  const rows = data.slice(1);
  const output = rows.map((row, index) => {
    const rawQuantity = row[5];
    const quantity = Number(rawQuantity);
    if (
      rawQuantity === '' ||
      rawQuantity === null ||
      rawQuantity === undefined ||
      Number.isNaN(quantity) ||
      quantity <= 0
    ) {
      throw new Error(`表單回應第 ${index + 2} 列的數量不正確:${rawQuantity}`);
    }

    return [
      index + 1,
      row[0],
      row[1],
      row[2],
      row[3],
      row[4],
      quantity,
      row[6] || ''
    ];
  });

  detailSheet.getRange(2, 1, detailSheet.getMaxRows() - 1, 8).clearContent();
  if (output.length) {
    detailSheet.getRange(2, 1, output.length, output[0].length).setValues(output);
  }
  SpreadsheetApp.getUi().alert('訂購明細整理完成!');
}

線上課程檢查點

完成檢查

  • 「訂購明細」第 2 列以下出現整理後資料。
  • 序號從 1 開始,依資料列順序排列。
  • 每列都有序號、填寫時間、姓名、飲料品項、甜度、冰塊、數量、備註。
  • 數量欄為數字。
  • 資料列數應對應表單回應中的測試資料。
  • 重複執行會先清除舊明細再寫入,不會不合理累加舊資料。

若沒有成功

  1. 確認已按下儲存,且 Apps Script 沒有顯示語法錯誤。
  2. 確認已經有至少一筆表單回應或 15 筆模擬資料。
  3. 確認執行的是 整理訂購明細
  4. 確認第一次授權已完成。
  5. 回到試算表重新整理後再查看「訂購明細」。
  6. 確認「訂購明細」工作表存在。
  7. 確認表單回應工作表名稱仍以「表單回應」開頭,或是 Form Responses 1
  8. 確認表單回應第一列欄位順序沒有被更動。
  9. 確認程式碼沒有貼在其他函式的大括號內。

回到課程的方法

  • 先重新執行 產生模擬訂購資料,再執行 整理訂購明細
  • 若欄位順序已被改動,先觀看固定版限制示範,再從通用化示範接回課程。

14GAS 04:統計品項數量

這一段會完成什麼

讀取「訂購明細」中的飲料品項與數量,統計每一種飲料的杯數。

1:35–1:55
功能:統計彙整 完成後:品項統計 核心函式:統計品項數量()
請保留前一段程式碼,將本段程式貼在程式碼.gs 最下方,不要覆蓋前面的函式。
程式碼
GAS 程式碼
function 統計品項數量() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('訂購明細');
  const statSheet = ss.getSheetByName('品項統計');

  if (!detailSheet || !statSheet) {
    SpreadsheetApp.getUi().alert('找不到訂購明細或品項統計,請先建立飲料訂購工作台。');
    return;
  }

  const data = detailSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('訂購明細沒有資料,請先整理訂購明細。');
    return;
  }

  const itemOrder = ['紅茶', '綠茶', '奶茶', '拿鐵'];
  const totals = new Map(itemOrder.map(item => [item, 0]));
  data.slice(1).forEach((row, index) => {
    const item = String(row[3] || '').trim();
    if (!item) return;

    const quantity = Number(row[6]);
    if (Number.isNaN(quantity)) {
      throw new Error(`訂購明細第 ${index + 2} 列的數量無法加總:${row[6]}`);
    }

    totals.set(item, (totals.get(item) || 0) + quantity);
  });

  const output = Array.from(totals.entries()).map(([item, quantity]) => [item, quantity]);
  const total = output.reduce((sum, row) => sum + row[1], 0);
  output.push(['總計', total]);

  statSheet.getRange(2, 1, statSheet.getMaxRows() - 1, 2).clearContent();
  if (output.length) {
    statSheet.getRange(2, 1, output.length, 2).setValues(output);
  }

  SpreadsheetApp.getUi().alert('品項統計完成!');
}

線上課程檢查點

完成檢查

  • 「品項統計」第 2 列以下出現紅茶、綠茶、奶茶、拿鐵與總計。
  • 杯數欄位有數字,而且總計不是 0。
  • 重複執行時,統計表會清除舊結果後重新寫入,不會持續累加舊統計。

若沒有成功

  1. 確認已按下儲存,且執行的是 統計品項數量
  2. 確認已先執行 整理訂購明細
  3. 確認「訂購明細」第 2 列以下有資料。
  4. 確認第一次授權已完成,並回到試算表重新整理。
  5. 確認工作表名稱仍為「訂購明細」與「品項統計」。
  6. 確認數量欄位是數字或可轉成數字。

回到課程的方法

  • 依序重新執行 整理訂購明細統計品項數量
  • 若資料列異常,重新產生模擬資料後再跑一次整理與統計。

15GAS 05:產生群組通知文字

這一段會完成什麼

把「品項統計」與「訂購明細」整理成可貼到群組的通知文字。

1:55–2:10
功能:通知輸出 完成後:群組通知文字 核心函式:產生群組通知文字()
請保留前一段程式碼,將本段程式貼在程式碼.gs 最下方,不要覆蓋前面的函式。
程式碼
GAS 程式碼
function 產生群組通知文字() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('訂購明細');
  const statSheet = ss.getSheetByName('品項統計');
  const noticeSheet = ss.getSheetByName('通知文字');

  if (!detailSheet || !statSheet || !noticeSheet) {
    SpreadsheetApp.getUi().alert('找不到訂購明細、品項統計或通知文字,請先建立飲料訂購工作台。');
    return;
  }

  const settings = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach(row => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key && value !== '') settings[key] = value;
    });
  }

  const processName = settings['流程名稱'] || '飲料訂購行政流';
  const noticeTitle = settings['通知標題'] || processName;
  const storeName = settings['店家名稱'] || '今天飲料店';
  const closeTime = settings['收單時間'] || settings['截止時間'] || '今天 11:00';

  const detailData = detailSheet.getDataRange().getValues();
  const statData = statSheet.getDataRange().getValues();
  if (detailData.length <= 1) {
    SpreadsheetApp.getUi().alert('訂購明細沒有資料,請先整理訂購明細。');
    return;
  }

  const statLines = statData.slice(1)
    .filter(row => row[0])
    .map(row => `${row[0]}:${row[1]}`);

  const detailLines = detailData.slice(1)
    .filter(row => row[2])
    .map(row => {
      const note = row[7] ? `(${row[7]})` : '';
      return `${row[2]}:${row[3]}/${row[4]}/${row[5]}/${row[6]} 杯${note}`;
    });

  if (!statLines.length || !detailLines.length) {
    SpreadsheetApp.getUi().alert('目前沒有可輸出的統計或訂購明細,請先完成整理與統計。');
    return;
  }

  const message = [
    `【${noticeTitle}】`,
    `店家名稱:${storeName}`,
    `收單時間:${closeTime}`,
    '',
    '品項統計:',
    ...statLines,
    '',
    '訂購明細:',
    ...detailLines,
    '',
    '確認提醒:請確認姓名、飲料、甜度、冰塊與數量是否正確。'
  ].join('\n');

  noticeSheet.getRange(2, 1, noticeSheet.getMaxRows() - 1, 2).clearContent();
  noticeSheet.getRange(2, 1, 1, 2).setValues([[new Date(), message]]);

  SpreadsheetApp.getUi().alert(`「${processName}」群組通知文字已產生於「通知文字」。`);
}

線上課程檢查點

完成檢查

  • 「通知文字」第 2 列有產生時間。
  • 第 2 列第 2 欄有完整通知內容。
  • 通知內容包含店家名稱、收單時間、品項統計與訂購明細。
  • 文字包含品項與數量。
  • 只產生草稿文字,不會直接寄信或發送外部訊息。

若沒有成功

  1. 確認已按下儲存,且執行的是 產生群組通知文字
  2. 確認已完成 GAS 03 整理明細。
  3. 確認已完成 GAS 04 品項統計。
  4. 確認第一次授權已完成,並回到試算表重新整理。
  5. 確認「通知文字」工作表存在。
  6. 確認工作表名稱仍為「訂購明細」「品項統計」「通知文字」。

回到課程的方法

  • 依序執行 GAS 03、GAS 04、GAS 05。
  • 若前面資料已混亂,使用課堂保底版後重新產生模擬資料,再從 GAS 03 接回。
  • 通知文字只是草稿,不會自動寄信或對外傳送。

16固定欄位版的限制

固定版程式會直接讀取表單回應中的第幾欄。這樣很適合初學,因為資料流很清楚,但表單欄位一被調整,後端就可能讀錯。

課堂操作:可重現的欄位破壞實驗

  1. 先複製一份試算表,或確認操作後可以復原。
  2. 在表單回應工作表中,交換「飲料品項」與「甜度」兩欄。
  3. 再次執行固定欄位版的「整理訂購明細」。
  4. 查看「訂購明細」,觀察程式雖然可以執行,飲料品項與甜度的欄位意義卻已錯置。
  5. 從這個結果理解固定欄號的風險,再比較欄位名稱對照的通用版。
  6. 完成觀察後,立即復原欄位順序或改回使用備份檔。
本步驟是刻意破壞資料欄位,只用於理解固定欄位程式的限制。完成觀察後,請復原欄位順序或使用備份檔。
固定版寫法 代表欄位
row[1]姓名
row[2]飲料品項
row[3]甜度
row[4]冰塊
row[5]數量
row[6]備註

好處

  • 初學時好理解。
  • 程式碼短。
  • 容易看懂表單回應怎麼變成整理明細。

限制

  • 表單欄位順序一改,後端可能讀錯。
  • 使用者新增欄位,可能造成欄位錯位。
  • 表單改成社團報名、設備借用或成果收件時,程式要改很多地方。
所以實務上會再往前走一步:不要只記第幾欄,而是讓後端知道每個欄位在流程中的角色。

17通用化示範:從固定欄位到欄位角色

固定版是為了看懂資料流:表單回應進來後,如何整理、統計、通知。通用行政整理工具則是為了讓表單可調整:欄位名稱、欄位順序或行政情境改變時,不需要到每一段程式裡重改第幾欄。

欄位角色不是一開始就要全部學會。完成固定版之後,再把「姓名、主要分類、數量、備註」轉成穩定的後端角色,會更容易理解欄位對照的用途。

課堂只做這 6 件事

  1. 先備份試算表,再交換表單回應工作表中的「飲料品項」與「甜度」兩欄。
  2. 執行固定版整理功能。
  3. 觀察程式可執行,但欄位意義已錯置。
  4. 介紹欄位名稱、欄位索引與欄位角色。
  5. 示範欄位對照表。
  6. 復原欄位順序,再修改一次欄位對照,例如把「飲料品項」改成「社團志願」。
後端角色 飲料訂購欄位 說明
name姓名訂購者、報名者或申請人。
category飲料品項要統計或分類的主要欄位。
option1甜度補充條件一。
option2冰塊補充條件二。
quantity數量可加總的數字欄位。
note備註補充說明、特殊需求或附件連結。

18GAS 06:建立通用行政工作台與欄位對照

課堂示範與課後延伸

GAS 06 用來示範欄位對照表。三小時課堂不要求完整重建通用行政整理工具;GAS 07、GAS 08、GAS 09 保留給課後進階練習。

這一段會完成什麼

建立通用版會用到的五張工作表,並加入「欄位對照」把表單欄位接到後端角色。

2:35–2:50 示範
功能:建立通用工作台 完成後:欄位對照與通用表 核心函式:建立通用行政工作台與欄位對照()
程式碼
GAS 程式碼
function 建立通用行政工作台與欄位對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  const existingSettings = {};
  const settingRows = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key) {
        settingRows[key] = index + 2;
        if (value !== '') existingSettings[key] = value;
      }
    });
  }

  const outputSheetNames = ['整理明細', '分類統計', '通知文字', '欄位對照'];
  const hasOutputData = outputSheetNames.some(name => {
    const sheet = ss.getSheetByName(name);
    return sheet && sheet.getLastRow() > 1;
  });
  if (hasOutputData) {
    const result = ui.alert(
      '重新建立通用工作台',
      '這個操作會清空整理明細、分類統計、通知文字與欄位對照,但不會刪除原始表單回應資料。適合課程重新操作或重設使用。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }

  const sheetConfigs = [
    { name: '整理明細', headers: ['序號', '填寫時間', 'name', 'category', 'option1', 'option2', 'quantity', 'note'] },
    { name: '分類統計', headers: ['分類', '數量'] },
    { name: '通知文字', headers: ['產生時間', '通知內容'] },
    { name: '系統設定', headers: ['key', 'value'] },
    { name: '欄位對照', headers: ['後端角色', '表單欄位名稱', '是否必要', '說明'] }
  ];

  sheetConfigs.forEach(config => {
    let sheet = ss.getSheetByName(config.name);
    if (!sheet) sheet = ss.insertSheet(config.name);
    if (config.name === '系統設定') {
      if (sheet.getLastRow() === 0) {
        sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
        sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
        sheet.setFrozenRows(1);
      }
    } else {
      sheet.clear();
      sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
      sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
      sheet.setFrozenRows(1);
    }
  });

  const updatedSettingSheet = ss.getSheetByName('系統設定');
  const defaultSettings = {
    '流程名稱': existingSettings['流程名稱'] || '飲料訂購行政流',
    '通知模式': existingSettings['通知模式'] || '群組通知',
    '通知標題': existingSettings['通知標題'] || '飲料訂購行政流',
    '店家名稱': existingSettings['店家名稱'] || '今天飲料店',
    '收單時間': existingSettings['收單時間'] || existingSettings['截止時間'] || '今天 11:00',
    '截止時間': existingSettings['截止時間'] || existingSettings['收單時間'] || '今天 11:00',
    '表單ID': existingSettings['表單ID'] || '',
    '表單編輯網址': existingSettings['表單編輯網址'] || '',
    '表單填寫網址': existingSettings['表單填寫網址'] || '',
    '回應試算表ID': existingSettings['回應試算表ID'] || ss.getId()
  };
  Object.entries(defaultSettings).forEach(([key, value]) => {
    if (settingRows[key]) {
      updatedSettingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      updatedSettingSheet.appendRow([key, value]);
    }
  });

  ss.getSheetByName('欄位對照').getRange(2, 1, 6, 4).setValues([
    ['name', '姓名', '是', '訂購者、報名者或申請人'],
    ['category', '飲料品項', '是', '要統計或分類的主要欄位'],
    ['option1', '甜度', '否', '補充條件一'],
    ['option2', '冰塊', '否', '補充條件二'],
    ['quantity', '數量', '是', '可加總的數字欄位'],
    ['note', '備註', '否', '補充說明、特殊需求或附件連結']
  ]);

  ss.getSheets().forEach(sheet => sheet.autoResizeColumns(1, Math.max(1, sheet.getLastColumn())));
  ui.alert('通用行政工作台與欄位對照建立完成!');
}

19GAS 07:整理通用明細

課後進階

這段保留給課後完整練習通用行政整理工具。課堂只需理解欄位對照如何讓整理流程不依賴固定欄位順序。

這一段會完成什麼

建立欄位索引,讀取欄位對照,再依角色取出 namecategoryquantity 等資料。

課後進階
功能:通用整理 完成後:整理明細 核心函式:整理通用明細()
程式碼
GAS 程式碼
function 整理通用明細() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = 尋找表單回應工作表();
  const detailSheet = ss.getSheetByName('整理明細');

  if (!responseSheet) {
    SpreadsheetApp.getUi().alert('找不到表單回應工作表,請先建立表單並送出測試資料。');
    return;
  }
  if (!detailSheet) {
    SpreadsheetApp.getUi().alert('找不到「整理明細」,請先執行建立通用行政工作台與欄位對照。');
    return;
  }

  const data = responseSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('目前沒有表單回應資料,請先填寫幾筆測試資料。');
    return;
  }

  const headers = data[0];
  const headerIndex = 建立欄位索引(headers);
  const fieldMap = 取得欄位對照();
  const rows = data.slice(1);

  try {
    const output = rows.map((row, index) => {
      const rawQuantity = 依角色取值(row, headerIndex, fieldMap, 'quantity');
      const quantity = Number(rawQuantity);
      if (
        rawQuantity === '' ||
        rawQuantity === null ||
        rawQuantity === undefined ||
        Number.isNaN(quantity) ||
        quantity <= 0
      ) {
        throw new Error(`表單回應第 ${index + 2} 列的數量不正確:${rawQuantity}`);
      }

      return [
        index + 1,
        row[0],
        依角色取值(row, headerIndex, fieldMap, 'name'),
        依角色取值(row, headerIndex, fieldMap, 'category'),
        依角色取值(row, headerIndex, fieldMap, 'option1'),
        依角色取值(row, headerIndex, fieldMap, 'option2'),
        quantity,
        依角色取值(row, headerIndex, fieldMap, 'note')
      ];
    });

    detailSheet.getRange(2, 1, detailSheet.getMaxRows() - 1, 8).clearContent();
    detailSheet.getRange(2, 1, output.length, output[0].length).setValues(output);
    SpreadsheetApp.getUi().alert('通用明細整理完成!');
  } catch (error) {
    SpreadsheetApp.getUi().alert(error.message);
    throw error;
  }
}

20GAS 08:統計分類數量

課後進階

這段示範如何依通用角色 categoryquantity 統計,不要求在三小時內完整實作。

這一段會完成什麼

不寫死紅茶、綠茶、奶茶、拿鐵,而是從整理明細的 category 自動加總 quantity

課後進階
功能:通用統計 完成後:分類統計 核心函式:統計分類數量()
程式碼
GAS 程式碼
function 統計分類數量() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('整理明細');
  const statSheet = ss.getSheetByName('分類統計');

  if (!detailSheet || !statSheet) {
    SpreadsheetApp.getUi().alert('找不到整理明細或分類統計,請先建立通用行政工作台與欄位對照。');
    return;
  }

  const data = detailSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('整理明細沒有資料,請先整理通用明細。');
    return;
  }

  const headers = data[0].map(header => String(header || '').trim());
  const categoryIndex = headers.indexOf('category');
  const quantityIndex = headers.indexOf('quantity');
  if (categoryIndex === -1 || quantityIndex === -1) {
    SpreadsheetApp.getUi().alert('整理明細缺少 category 或 quantity 欄位。');
    return;
  }

  const totals = new Map();
  data.slice(1).forEach((row, index) => {
    const category = String(row[categoryIndex] || '').trim();
    if (!category) return;

    const quantity = Number(row[quantityIndex]);
    if (Number.isNaN(quantity)) {
      throw new Error(`整理明細第 ${index + 2} 列的 quantity 無法加總:${row[quantityIndex]}`);
    }

    totals.set(category, (totals.get(category) || 0) + quantity);
  });

  const output = Array.from(totals.entries()).map(([category, quantity]) => [category, quantity]);
  const total = output.reduce((sum, row) => sum + row[1], 0);
  output.push(['總計', total]);

  statSheet.getRange(2, 1, statSheet.getMaxRows() - 1, 2).clearContent();
  if (output.length) {
    statSheet.getRange(2, 1, output.length, 2).setValues(output);
  }

  SpreadsheetApp.getUi().alert('分類統計完成!');
}

21GAS 09:產生通用通知文字

課後進階

這段把通用整理與統計結果轉成通知文字。課堂會說明概念,完整操作可課後再做。

這一段會完成什麼

從系統設定讀取流程名稱、通知標題與截止時間,再彙整分類統計與整理明細。

課後進階
功能:通用通知 完成後:通用通知文字 核心函式:產生通用通知文字()
程式碼
GAS 程式碼
function 產生通用通知文字() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('整理明細');
  const statSheet = ss.getSheetByName('分類統計');
  const noticeSheet = ss.getSheetByName('通知文字');

  if (!detailSheet || !statSheet || !noticeSheet) {
    SpreadsheetApp.getUi().alert('找不到整理明細、分類統計或通知文字,請先建立通用行政工作台與欄位對照。');
    return;
  }

  const settings = 取得系統設定();
  const processName = settings['流程名稱'] || '飲料訂購行政流';
  const noticeTitle = settings['通知標題'] || processName;
  const deadline = settings['截止時間'] || '今天 11:00';

  const detailData = detailSheet.getDataRange().getValues();
  const statData = statSheet.getDataRange().getValues();
  if (detailData.length <= 1) {
    SpreadsheetApp.getUi().alert('整理明細沒有資料,請先整理通用明細。');
    return;
  }

  const detailHeaders = detailData[0].map(header => String(header || '').trim());
  const idx = role => detailHeaders.indexOf(role);
  const statLines = statData.slice(1)
    .filter(row => row[0])
    .map(row => `${row[0]}:${row[1]}`);

  const detailLines = detailData.slice(1)
    .filter(row => row[idx('name')])
    .map(row => {
      const note = row[idx('note')] ? `(${row[idx('note')]})` : '';
      return `${row[idx('name')]}:${row[idx('category')]}/${row[idx('option1')]}/${row[idx('option2')]}/${row[idx('quantity')]}${note}`;
    });

  if (!statLines.length || !detailLines.length) {
    SpreadsheetApp.getUi().alert('目前沒有可輸出的分類統計或整理明細,請先完成整理與統計。');
    return;
  }

  const message = [
    `【${noticeTitle}】`,
    `截止時間:${deadline}`,
    '',
    '分類統計:',
    ...statLines,
    '',
    '明細:',
    ...detailLines,
    '',
    '請確認以上資料是否正確。'
  ].join('\n');

  noticeSheet.getRange(2, 1, noticeSheet.getMaxRows() - 1, 2).clearContent();
  noticeSheet.getRange(2, 1, 1, 2).setValues([[new Date(), message]]);

  SpreadsheetApp.getUi().alert(`「${processName}」通知文字已產生於「通知文字」。`);
}

22GAS 10:產生行政情境範例對照

這一段會完成什麼

建立一張「行政情境範例對照」,看懂同一套欄位角色如何改造成不同表單欄位。

2:50–3:00 簡單改造
功能:情境改造 完成後:行政情境範例對照 核心函式:產生行政情境範例對照()
程式碼
GAS 程式碼
function 產生行政情境範例對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName('行政情境範例對照');
  if (sheet && sheet.getLastRow() > 1) {
    const ui = SpreadsheetApp.getUi();
    const result = ui.alert(
      '重新建立行政情境範例對照',
      '這個操作會清空目前的行政情境範例對照。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }
  if (!sheet) sheet = ss.insertSheet('行政情境範例對照');
  sheet.clear();

  const rows = [
    ['行政情境', 'name', 'category', 'option1', 'option2', 'quantity', 'note'],
    ['飲料訂購', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'],
    ['社團報名', '學生姓名', '社團志願', '年級', '班級', '報名人數', '特殊需求'],
    ['研習報名', '姓名', '研習場次', '服務單位', '職稱', '報名人數', '研習需求'],
    ['設備借用', '借用人', '設備名稱', '借用日期', '歸還日期', '借用數量', '用途說明'],
    ['成果收件', '繳交人', '成果類型', '班級', '繳交狀態', '件數', '附件連結'],
    ['值勤調查', '姓名', '值勤時段', '可支援日期', '任務類型', '可支援人數', '備註']
  ];

  sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
  sheet.getRange(1, 1, 1, rows[0].length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, rows[0].length);

  SpreadsheetApp.getUi().alert('行政情境範例對照已建立完成!');
}
通用版輔助函式

通用欄位版的核心在這組輔助函式。表頭與欄位對照都會先做 trim(),避免欄位名稱前後空白造成對不到;系統設定與表單回應工作表也集中由輔助函式處理。

如果程式碼.gs 已保留 GAS 01-1 的固定版 尋找表單回應工作表,不要再把下方同名通用版輔助函式直接累加貼上。請改用課後通用完整版整份取代,或先刪除舊的同名函式。
GAS 程式碼
function 建立欄位索引(headers) {
  const headerIndex = {};
  headers.forEach((header, index) => {
    const name = String(header || '').trim();
    if (name) headerIndex[name] = index;
  });
  return headerIndex;
}

function 取得欄位對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('欄位對照');
  if (!sheet) throw new Error('找不到「欄位對照」,請先建立通用行政工作台與欄位對照。');

  const data = sheet.getDataRange().getValues();
  const fieldMap = {};
  data.slice(1).forEach(row => {
    const role = String(row[0] || '').trim();
    const fieldName = String(row[1] || '').trim();
    const required = String(row[2] || '').trim() === '是';
    const description = String(row[3] || '').trim();
    if (role) fieldMap[role] = { fieldName, required, description };
  });
  return fieldMap;
}

function 依角色取值(row, headerIndex, fieldMap, role) {
  const requiredRoles = ['name', 'category', 'quantity'];
  const config = fieldMap[role];

  if (!config) {
    if (requiredRoles.includes(role)) throw new Error(`欄位對照缺少必要角色:${role}`);
    return '';
  }

  const fieldName = String(config.fieldName || '').trim();
  if (!fieldName) {
    if (config.required) throw new Error(`必要角色 ${role} 沒有設定表單欄位名稱。`);
    return '';
  }

  const index = headerIndex[fieldName];
  if (index === undefined) {
    if (config.required) throw new Error(`必要欄位不存在:欄位對照中的「${fieldName}」找不到對應的表單欄位。`);
    return '';
  }

  const value = row[index];
  return value === null || value === undefined ? '' : value;
}

function 取得系統設定() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('系統設定');
  const settings = {};

  if (!sheet) return settings;

  const data = sheet.getDataRange().getValues();
  data.slice(1).forEach(row => {
    const key = String(row[0] || '').trim();
    const value = row[1];
    if (key && value !== '') settings[key] = value;
  });

  return settings;
}

function 尋找表單回應工作表() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const excludedNames = new Set([
    '訂購明細',
    '品項統計',
    '通知文字',
    '系統設定',
    '整理明細',
    '分類統計',
    '欄位對照',
    '行政情境',
    '行政情境範例對照'
  ]);
  const mappedRequiredHeaders = [];
  const fieldMapSheet = ss.getSheetByName('欄位對照');
  if (fieldMapSheet && fieldMapSheet.getLastRow() > 1) {
    const requiredRoles = new Set(['name', 'category', 'quantity']);
    fieldMapSheet.getDataRange().getDisplayValues().slice(1).forEach(row => {
      const role = String(row[0] || '').trim();
      const fieldName = String(row[1] || '').trim();
      if (requiredRoles.has(role) && fieldName) mappedRequiredHeaders.push(fieldName);
    });
  }

  return ss.getSheets().find(sheet => {
    const name = sheet.getName();
    if (excludedNames.has(name)) return false;
    if (sheet.getLastColumn() < 1) return false;
    const headers = sheet
      .getRange(1, 1, 1, sheet.getLastColumn())
      .getDisplayValues()[0]
      .map(value => String(value).trim());
    const matchesFixedDrinkOrder = (
      headers.includes('時間戳記') &&
      headers.includes('姓名') &&
      headers.includes('飲料品項')
    );
    const matchesFieldMap = (
      headers.includes('時間戳記') &&
      mappedRequiredHeaders.length === 3 &&
      mappedRequiredHeaders.every(header => headers.includes(header))
    );
    return matchesFixedDrinkOrder || matchesFieldMap;
  }) || null;
}

23完整程式碼與保底版本

課堂進行中請先使用分段程式碼

請依照 GAS 01 至 GAS 05 的順序操作,不要先貼上所有完整程式碼。只有在操作失敗、需要快速回到課程進度時,才使用課堂保底版;使用前請先備份目前 Apps Script 程式碼。

使用保底完整版時,請先清除原有分段程式,再完整貼上本區程式,避免同名函式重複。

A. 課堂分段程式碼(預設展開)

這是課堂主要使用內容。請依序完成 GAS 01 至 GAS 05;每一段都有自己的複製按鈕與線上課程檢查點。

  • GAS 01:建立飲料訂購表單。
  • GAS 01-1:產生 15 筆模擬訂購資料。
  • GAS 02:建立飲料訂購工作台。
  • GAS 03:整理訂購明細。
  • GAS 04:統計品項數量。
  • GAS 05:產生群組通知文字。
B. 課堂保底版:固定版完整程式碼(操作失敗或中途掉隊時使用)
操作失敗或中途掉隊時使用。使用前請先備份目前 Apps Script 程式碼。使用保底完整版時,請先清除原有分段程式,再完整貼上本區程式,避免同名函式重複。貼上固定版完整 GS 後,請先儲存,再回到試算表重新整理,從「飲料訂購固定版工具」選單接回課程。

固定版完整 GS 程式碼:先做出飲料訂購工具

這個版本適合前半段練習。貼上後儲存,回到試算表重新整理,會看到「飲料訂購固定版工具」選單。功能包含建立表單、產生模擬訂購資料、建立工作台、整理明細、統計品項與產生群組通知。

固定版
固定版完整 GS
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('飲料訂購固定版工具')
    .addItem('① 建立飲料訂購表單', '建立飲料訂購表單')
    .addItem('①-1 產生 15 筆模擬訂購資料', '產生模擬訂購資料')
    .addItem('② 建立飲料訂購工作台', '建立飲料訂購工作台')
    .addItem('③ 整理訂購明細', '整理訂購明細')
    .addItem('④ 統計品項數量', '統計品項數量')
    .addItem('⑤ 產生群組通知文字', '產生群組通知文字')
    .addToUi();
}

function 建立飲料訂購表單() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();

  let settingSheet = ss.getSheetByName('系統設定');
  if (!settingSheet) {
    settingSheet = ss.insertSheet('系統設定');
  }
  if (settingSheet.getLastRow() === 0) {
    settingSheet.getRange(1, 1, 1, 2).setValues([['key', 'value']]);
  }

  const settings = {};
  const settingRows = {};
  settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
    const key = String(row[0] || '').trim();
    const value = row[1];
    if (key) {
      settingRows[key] = index + 2;
      if (value !== '') settings[key] = value;
    }
  });

  let existingForm = null;
  if (settings['表單ID']) {
    try {
      existingForm = FormApp.openById(String(settings['表單ID']).trim());
    } catch (error) {
      existingForm = null;
    }
  }
  if (!existingForm && settings['表單編輯網址']) {
    try {
      existingForm = FormApp.openByUrl(String(settings['表單編輯網址']).trim());
    } catch (error) {
      existingForm = null;
    }
  }
  if (!existingForm && settings['表單填寫網址']) {
    try {
      existingForm = FormApp.openByUrl(String(settings['表單填寫網址']).trim());
    } catch (error) {
      existingForm = null;
    }
  }

  if (existingForm) {
    const result = ui.alert(
      '已存在飲料訂購表單',
      '系統設定中已經有有效的表單資料。是否仍要建立一份新的表單?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) {
      ui.alert('已取消建立新表單。');
      return;
    }
  }

  const form = FormApp.create('飲料訂購表單');
  form.setDescription('請填寫飲料訂購資料。這份表單用來練習行政收件:收件、整理、統計與通知。');

  form.addTextItem().setTitle('姓名').setRequired(true);
  form.addListItem().setTitle('飲料品項').setChoiceValues(['紅茶', '綠茶', '奶茶', '拿鐵']).setRequired(true);
  form.addMultipleChoiceItem().setTitle('甜度').setChoiceValues(['正常糖', '半糖', '微糖', '無糖']).setRequired(true);
  form.addMultipleChoiceItem().setTitle('冰塊').setChoiceValues(['正常冰', '少冰', '微冰', '去冰']).setRequired(true);
  form.addListItem().setTitle('數量').setChoiceValues(['1', '2', '3', '4', '5']).setRequired(true);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);

  form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());

  const updates = {
    '流程名稱': settings['流程名稱'] || '飲料訂購行政流',
    '通知模式': settings['通知模式'] || '群組通知',
    '通知標題': settings['通知標題'] || '飲料訂購行政流',
    '店家名稱': settings['店家名稱'] || '今天飲料店',
    '收單時間': settings['收單時間'] || '今天 11:00',
    '截止時間': settings['截止時間'] || settings['收單時間'] || '今天 11:00',
    '表單ID': form.getId(),
    '表單編輯網址': form.getEditUrl(),
    '表單填寫網址': form.getPublishedUrl(),
    '回應試算表ID': ss.getId()
  };

  Object.entries(updates).forEach(([key, value]) => {
    if (settingRows[key]) {
      settingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      settingSheet.appendRow([key, value]);
    }
  });

  ui.alert('飲料訂購表單已建立完成!\n\n填寫網址:\n' + form.getPublishedUrl());
}

function 產生模擬訂購資料() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let responseSheet = 尋找表單回應工作表();

  if (!responseSheet) {
    const excludedNames = new Set([
      '訂購明細',
      '品項統計',
      '通知文字',
      '系統設定',
      '整理明細',
      '分類統計',
      '欄位對照',
      '行政情境',
      '行政情境範例對照'
    ]);
    responseSheet = ss.getSheets().find(sheet => {
      if (excludedNames.has(sheet.getName()) || sheet.getLastColumn() < 1) return false;
      const candidateHeaders = sheet
        .getRange(1, 1, 1, sheet.getLastColumn())
        .getDisplayValues()[0]
        .map(value => String(value).trim());
      const name = sheet.getName();
      const hasCommonResponseName =
        name.startsWith('表單回應') ||
        name.startsWith('表單回覆') ||
        name.startsWith('Form Responses');
      return candidateHeaders.includes('時間戳記') || hasCommonResponseName;
    }) || null;
  }

  if (!responseSheet) {
    responseSheet = ss.insertSheet('表單回應 1');
  }

  const expectedHeaders = ['時間戳記', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'];
  const currentHeaders = responseSheet
    .getRange(1, 1, 1, expectedHeaders.length)
    .getDisplayValues()[0]
    .map(value => String(value).trim());
  const headerIsValid = expectedHeaders.every(
    (header, index) => currentHeaders[index] === header
  );

  if (!headerIsValid) {
    if (responseSheet.getLastRow() > 0) {
      throw new Error(
        '找到的表單回應工作表欄位與課程預期不一致,請確認第一列是否為:時間戳記、姓名、飲料品項、甜度、冰塊、數量、備註。'
      );
    }
    responseSheet.getRange(1, 1, 1, expectedHeaders.length).setValues([expectedHeaders]);
    responseSheet.getRange(1, 1, 1, expectedHeaders.length).setFontWeight('bold');
    responseSheet.setFrozenRows(1);
  }

  const mockRows = [
    ['王小明', '紅茶', '正常糖', '少冰', 1, ''],
    ['陳小華', '奶茶', '半糖', '去冰', 2, '加珍珠'],
    ['林品安', '綠茶', '微糖', '微冰', 1, ''],
    ['黃子晴', '拿鐵', '無糖', '少冰', 1, '不要吸管'],
    ['張育誠', '紅茶', '半糖', '正常冰', 3, ''],
    ['李佳蓉', '奶茶', '微糖', '少冰', 1, ''],
    ['吳柏翰', '綠茶', '無糖', '去冰', 2, ''],
    ['劉怡君', '拿鐵', '正常糖', '正常冰', 1, '加冰塊'],
    ['蔡承恩', '紅茶', '微糖', '去冰', 1, ''],
    ['楊雅婷', '奶茶', '半糖', '微冰', 2, '少甜一點'],
    ['周冠宇', '綠茶', '正常糖', '少冰', 1, ''],
    ['鄭羽庭', '拿鐵', '半糖', '去冰', 1, ''],
    ['許哲維', '紅茶', '無糖', '微冰', 2, ''],
    ['洪詩涵', '奶茶', '正常糖', '少冰', 1, '加椰果'],
    ['郭家豪', '綠茶', '微糖', '正常冰', 3, '']
  ];

  const baseTime = new Date();
  const output = mockRows.map((row, index) => {
    const timestamp = new Date(baseTime.getTime() + index * 60 * 1000);
    return [timestamp, ...row];
  });

  const maxRows = responseSheet.getMaxRows();
  if (maxRows > 1) {
    responseSheet.getRange(2, 1, maxRows - 1, expectedHeaders.length).clearContent();
  }

  responseSheet.getRange(2, 1, output.length, expectedHeaders.length).setValues(output);
  responseSheet.autoResizeColumns(1, expectedHeaders.length);

  SpreadsheetApp.getUi().alert('已產生 15 筆模擬訂購資料,可以繼續執行整理訂購明細。');
}

function 建立飲料訂購工作台() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  const existingSettings = {};
  const settingRows = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key) {
        settingRows[key] = index + 2;
        if (value !== '') existingSettings[key] = value;
      }
    });
  }

  const outputSheetNames = ['訂購明細', '品項統計', '通知文字'];
  const hasOutputData = outputSheetNames.some(name => {
    const sheet = ss.getSheetByName(name);
    return sheet && sheet.getLastRow() > 1;
  });
  if (hasOutputData) {
    const result = ui.alert(
      '重新建立工作台',
      '這個操作會清空訂購明細、品項統計與通知文字,但不會刪除原始表單回應資料。適合課程重新操作或重設使用。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }

  const sheetConfigs = [
    { name: '訂購明細', headers: ['序號', '填寫時間', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'] },
    { name: '品項統計', headers: ['飲料品項', '杯數'] },
    { name: '通知文字', headers: ['產生時間', '通知內容'] },
    { name: '系統設定', headers: ['key', 'value'] }
  ];

  sheetConfigs.forEach(config => {
    let sheet = ss.getSheetByName(config.name);
    if (!sheet) sheet = ss.insertSheet(config.name);
    if (config.name === '系統設定') {
      if (sheet.getLastRow() === 0) {
        sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
        sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
        sheet.setFrozenRows(1);
      }
    } else {
      sheet.clear();
      sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
      sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
      sheet.setFrozenRows(1);
    }
  });

  const updatedSettingSheet = ss.getSheetByName('系統設定');
  const defaultSettings = {
    '流程名稱': existingSettings['流程名稱'] || '飲料訂購行政流',
    '通知模式': existingSettings['通知模式'] || '群組通知',
    '通知標題': existingSettings['通知標題'] || '飲料訂購行政流',
    '店家名稱': existingSettings['店家名稱'] || '今天飲料店',
    '收單時間': existingSettings['收單時間'] || existingSettings['截止時間'] || '今天 11:00',
    '截止時間': existingSettings['截止時間'] || existingSettings['收單時間'] || '今天 11:00',
    '表單ID': existingSettings['表單ID'] || '',
    '表單編輯網址': existingSettings['表單編輯網址'] || '',
    '表單填寫網址': existingSettings['表單填寫網址'] || '',
    '回應試算表ID': existingSettings['回應試算表ID'] || ss.getId()
  };
  Object.entries(defaultSettings).forEach(([key, value]) => {
    if (settingRows[key]) {
      updatedSettingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      updatedSettingSheet.appendRow([key, value]);
    }
  });

  ss.getSheets().forEach(sheet => sheet.autoResizeColumns(1, Math.max(1, sheet.getLastColumn())));
  ui.alert('飲料訂購工作台建立完成!');
}

function 整理訂購明細() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = 尋找表單回應工作表();
  const detailSheet = ss.getSheetByName('訂購明細');

  if (!responseSheet) {
    SpreadsheetApp.getUi().alert('找不到表單回應工作表,請先建立表單並送出測試資料。');
    return;
  }
  if (!detailSheet) {
    SpreadsheetApp.getUi().alert('找不到「訂購明細」,請先執行建立飲料訂購工作台。');
    return;
  }

  const data = responseSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('目前沒有表單回應資料,請先填寫幾筆測試資料。');
    return;
  }

  const rows = data.slice(1);
  const output = rows.map((row, index) => {
    const rawQuantity = row[5];
    const quantity = Number(rawQuantity);
    if (
      rawQuantity === '' ||
      rawQuantity === null ||
      rawQuantity === undefined ||
      Number.isNaN(quantity) ||
      quantity <= 0
    ) {
      throw new Error(`表單回應第 ${index + 2} 列的數量不正確:${rawQuantity}`);
    }

    return [
      index + 1,
      row[0],
      row[1],
      row[2],
      row[3],
      row[4],
      quantity,
      row[6] || ''
    ];
  });

  detailSheet.getRange(2, 1, detailSheet.getMaxRows() - 1, 8).clearContent();
  if (output.length) {
    detailSheet.getRange(2, 1, output.length, output[0].length).setValues(output);
  }
  SpreadsheetApp.getUi().alert('訂購明細整理完成!');
}

function 統計品項數量() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('訂購明細');
  const statSheet = ss.getSheetByName('品項統計');

  if (!detailSheet || !statSheet) {
    SpreadsheetApp.getUi().alert('找不到訂購明細或品項統計,請先建立飲料訂購工作台。');
    return;
  }

  const data = detailSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('訂購明細沒有資料,請先整理訂購明細。');
    return;
  }

  const itemOrder = ['紅茶', '綠茶', '奶茶', '拿鐵'];
  const totals = new Map(itemOrder.map(item => [item, 0]));
  data.slice(1).forEach((row, index) => {
    const item = String(row[3] || '').trim();
    if (!item) return;

    const quantity = Number(row[6]);
    if (Number.isNaN(quantity)) {
      throw new Error(`訂購明細第 ${index + 2} 列的數量無法加總:${row[6]}`);
    }

    totals.set(item, (totals.get(item) || 0) + quantity);
  });

  const output = Array.from(totals.entries()).map(([item, quantity]) => [item, quantity]);
  const total = output.reduce((sum, row) => sum + row[1], 0);
  output.push(['總計', total]);

  statSheet.getRange(2, 1, statSheet.getMaxRows() - 1, 2).clearContent();
  if (output.length) {
    statSheet.getRange(2, 1, output.length, 2).setValues(output);
  }

  SpreadsheetApp.getUi().alert('品項統計完成!');
}

function 產生群組通知文字() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('訂購明細');
  const statSheet = ss.getSheetByName('品項統計');
  const noticeSheet = ss.getSheetByName('通知文字');

  if (!detailSheet || !statSheet || !noticeSheet) {
    SpreadsheetApp.getUi().alert('找不到訂購明細、品項統計或通知文字,請先建立飲料訂購工作台。');
    return;
  }

  const settings = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach(row => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key && value !== '') settings[key] = value;
    });
  }

  const processName = settings['流程名稱'] || '飲料訂購行政流';
  const noticeTitle = settings['通知標題'] || processName;
  const storeName = settings['店家名稱'] || '今天飲料店';
  const closeTime = settings['收單時間'] || settings['截止時間'] || '今天 11:00';

  const detailData = detailSheet.getDataRange().getValues();
  const statData = statSheet.getDataRange().getValues();
  if (detailData.length <= 1) {
    SpreadsheetApp.getUi().alert('訂購明細沒有資料,請先整理訂購明細。');
    return;
  }

  const statLines = statData.slice(1)
    .filter(row => row[0])
    .map(row => `${row[0]}:${row[1]}`);

  const detailLines = detailData.slice(1)
    .filter(row => row[2])
    .map(row => {
      const note = row[7] ? `(${row[7]})` : '';
      return `${row[2]}:${row[3]}/${row[4]}/${row[5]}/${row[6]} 杯${note}`;
    });

  if (!statLines.length || !detailLines.length) {
    SpreadsheetApp.getUi().alert('目前沒有可輸出的統計或訂購明細,請先完成整理與統計。');
    return;
  }

  const message = [
    `【${noticeTitle}】`,
    `店家名稱:${storeName}`,
    `收單時間:${closeTime}`,
    '',
    '品項統計:',
    ...statLines,
    '',
    '訂購明細:',
    ...detailLines,
    '',
    '確認提醒:請確認姓名、飲料、甜度、冰塊與數量是否正確。'
  ].join('\n');

  noticeSheet.getRange(2, 1, noticeSheet.getMaxRows() - 1, 2).clearContent();
  noticeSheet.getRange(2, 1, 1, 2).setValues([[new Date(), message]]);

  SpreadsheetApp.getUi().alert(`「${processName}」群組通知文字已產生於「通知文字」。`);
}

function 建立欄位索引(headers) {
  const headerIndex = {};
  headers.forEach((header, index) => {
    const name = String(header || '').trim();
    if (name) headerIndex[name] = index;
  });
  return headerIndex;
}

function 取得欄位對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('欄位對照');
  if (!sheet) throw new Error('找不到「欄位對照」,請先建立通用行政工作台與欄位對照。');

  const data = sheet.getDataRange().getValues();
  const fieldMap = {};
  data.slice(1).forEach(row => {
    const role = String(row[0] || '').trim();
    const fieldName = String(row[1] || '').trim();
    const required = String(row[2] || '').trim() === '是';
    const description = String(row[3] || '').trim();
    if (role) fieldMap[role] = { fieldName, required, description };
  });
  return fieldMap;
}

function 依角色取值(row, headerIndex, fieldMap, role) {
  const requiredRoles = ['name', 'category', 'quantity'];
  const config = fieldMap[role];

  if (!config) {
    if (requiredRoles.includes(role)) throw new Error(`欄位對照缺少必要角色:${role}`);
    return '';
  }

  const fieldName = String(config.fieldName || '').trim();
  if (!fieldName) {
    if (config.required) throw new Error(`必要角色 ${role} 沒有設定表單欄位名稱。`);
    return '';
  }

  const index = headerIndex[fieldName];
  if (index === undefined) {
    if (config.required) throw new Error(`必要欄位不存在:欄位對照中的「${fieldName}」找不到對應的表單欄位。`);
    return '';
  }

  const value = row[index];
  return value === null || value === undefined ? '' : value;
}

function 取得系統設定() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('系統設定');
  const settings = {};

  if (!sheet) return settings;

  const data = sheet.getDataRange().getValues();
  data.slice(1).forEach(row => {
    const key = String(row[0] || '').trim();
    const value = row[1];
    if (key && value !== '') settings[key] = value;
  });

  return settings;
}

function 尋找表單回應工作表() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const excludedNames = new Set([
    '訂購明細',
    '品項統計',
    '通知文字',
    '系統設定',
    '整理明細',
    '分類統計',
    '欄位對照',
    '行政情境',
    '行政情境範例對照'
  ]);
  const mappedRequiredHeaders = [];
  const fieldMapSheet = ss.getSheetByName('欄位對照');
  if (fieldMapSheet && fieldMapSheet.getLastRow() > 1) {
    const requiredRoles = new Set(['name', 'category', 'quantity']);
    fieldMapSheet.getDataRange().getDisplayValues().slice(1).forEach(row => {
      const role = String(row[0] || '').trim();
      const fieldName = String(row[1] || '').trim();
      if (requiredRoles.has(role) && fieldName) mappedRequiredHeaders.push(fieldName);
    });
  }

  return ss.getSheets().find(sheet => {
    const name = sheet.getName();
    if (excludedNames.has(name)) return false;
    if (sheet.getLastColumn() < 1) return false;
    const headers = sheet
      .getRange(1, 1, 1, sheet.getLastColumn())
      .getDisplayValues()[0]
      .map(value => String(value).trim());
    const matchesFixedDrinkOrder = (
      headers.includes('時間戳記') &&
      headers.includes('姓名') &&
      headers.includes('飲料品項')
    );
    const matchesFieldMap = (
      headers.includes('時間戳記') &&
      mappedRequiredHeaders.length === 3 &&
      mappedRequiredHeaders.every(header => headers.includes(header))
    );
    return matchesFixedDrinkOrder || matchesFieldMap;
  }) || null;
}
C. 課後進階版:通用行政後端完整程式碼

不要求三小時內全部完成

這裡包含 GAS 06~09、欄位對照、通用整理、通用統計、通用通知與輔助函式,適合課後完整練習通用行政整理工具。

通用版完整 GS 程式碼:用欄位對照驅動後端

這個版本適合課後進階練習。貼上後儲存,回到試算表重新整理,會看到「通用行政後端工具」選單。功能包含建立欄位對照、整理通用明細、統計分類數量、產生通用通知與行政情境對照。

通用版
通用版完整 GS
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('通用行政後端工具')
    .addItem('① 建立通用行政工作台與欄位對照', '建立通用行政工作台與欄位對照')
    .addItem('② 整理通用明細', '整理通用明細')
    .addItem('③ 統計分類數量', '統計分類數量')
    .addItem('④ 產生通用通知文字', '產生通用通知文字')
    .addItem('⑤ 產生行政情境範例對照', '產生行政情境範例對照')
    .addToUi();
}

function 建立通用行政工作台與欄位對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  const existingSettings = {};
  const settingRows = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key) {
        settingRows[key] = index + 2;
        if (value !== '') existingSettings[key] = value;
      }
    });
  }

  const outputSheetNames = ['整理明細', '分類統計', '通知文字', '欄位對照'];
  const hasOutputData = outputSheetNames.some(name => {
    const sheet = ss.getSheetByName(name);
    return sheet && sheet.getLastRow() > 1;
  });
  if (hasOutputData) {
    const result = ui.alert(
      '重新建立通用工作台',
      '這個操作會清空整理明細、分類統計、通知文字與欄位對照,但不會刪除原始表單回應資料。適合課程重新操作或重設使用。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }

  const sheetConfigs = [
    { name: '整理明細', headers: ['序號', '填寫時間', 'name', 'category', 'option1', 'option2', 'quantity', 'note'] },
    { name: '分類統計', headers: ['分類', '數量'] },
    { name: '通知文字', headers: ['產生時間', '通知內容'] },
    { name: '系統設定', headers: ['key', 'value'] },
    { name: '欄位對照', headers: ['後端角色', '表單欄位名稱', '是否必要', '說明'] }
  ];

  sheetConfigs.forEach(config => {
    let sheet = ss.getSheetByName(config.name);
    if (!sheet) sheet = ss.insertSheet(config.name);
    if (config.name === '系統設定') {
      if (sheet.getLastRow() === 0) {
        sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
        sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
        sheet.setFrozenRows(1);
      }
    } else {
      sheet.clear();
      sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
      sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
      sheet.setFrozenRows(1);
    }
  });

  const updatedSettingSheet = ss.getSheetByName('系統設定');
  const defaultSettings = {
    '流程名稱': existingSettings['流程名稱'] || '飲料訂購行政流',
    '通知模式': existingSettings['通知模式'] || '群組通知',
    '通知標題': existingSettings['通知標題'] || '飲料訂購行政流',
    '店家名稱': existingSettings['店家名稱'] || '今天飲料店',
    '收單時間': existingSettings['收單時間'] || existingSettings['截止時間'] || '今天 11:00',
    '截止時間': existingSettings['截止時間'] || existingSettings['收單時間'] || '今天 11:00',
    '表單ID': existingSettings['表單ID'] || '',
    '表單編輯網址': existingSettings['表單編輯網址'] || '',
    '表單填寫網址': existingSettings['表單填寫網址'] || '',
    '回應試算表ID': existingSettings['回應試算表ID'] || ss.getId()
  };
  Object.entries(defaultSettings).forEach(([key, value]) => {
    if (settingRows[key]) {
      updatedSettingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      updatedSettingSheet.appendRow([key, value]);
    }
  });

  ss.getSheetByName('欄位對照').getRange(2, 1, 6, 4).setValues([
    ['name', '姓名', '是', '訂購者、報名者或申請人'],
    ['category', '飲料品項', '是', '要統計或分類的主要欄位'],
    ['option1', '甜度', '否', '補充條件一'],
    ['option2', '冰塊', '否', '補充條件二'],
    ['quantity', '數量', '是', '可加總的數字欄位'],
    ['note', '備註', '否', '補充說明、特殊需求或附件連結']
  ]);

  ss.getSheets().forEach(sheet => sheet.autoResizeColumns(1, Math.max(1, sheet.getLastColumn())));
  ui.alert('通用行政工作台與欄位對照建立完成!');
}

function 整理通用明細() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = 尋找表單回應工作表();
  const detailSheet = ss.getSheetByName('整理明細');

  if (!responseSheet) {
    SpreadsheetApp.getUi().alert('找不到表單回應工作表,請先建立表單並送出測試資料。');
    return;
  }
  if (!detailSheet) {
    SpreadsheetApp.getUi().alert('找不到「整理明細」,請先執行建立通用行政工作台與欄位對照。');
    return;
  }

  const data = responseSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('目前沒有表單回應資料,請先填寫幾筆測試資料。');
    return;
  }

  const headers = data[0];
  const headerIndex = 建立欄位索引(headers);
  const fieldMap = 取得欄位對照();
  const rows = data.slice(1);

  try {
    const output = rows.map((row, index) => {
      const rawQuantity = 依角色取值(row, headerIndex, fieldMap, 'quantity');
      const quantity = Number(rawQuantity);
      if (
        rawQuantity === '' ||
        rawQuantity === null ||
        rawQuantity === undefined ||
        Number.isNaN(quantity) ||
        quantity <= 0
      ) {
        throw new Error(`表單回應第 ${index + 2} 列的數量不正確:${rawQuantity}`);
      }

      return [
        index + 1,
        row[0],
        依角色取值(row, headerIndex, fieldMap, 'name'),
        依角色取值(row, headerIndex, fieldMap, 'category'),
        依角色取值(row, headerIndex, fieldMap, 'option1'),
        依角色取值(row, headerIndex, fieldMap, 'option2'),
        quantity,
        依角色取值(row, headerIndex, fieldMap, 'note')
      ];
    });

    detailSheet.getRange(2, 1, detailSheet.getMaxRows() - 1, 8).clearContent();
    detailSheet.getRange(2, 1, output.length, output[0].length).setValues(output);
    SpreadsheetApp.getUi().alert('通用明細整理完成!');
  } catch (error) {
    SpreadsheetApp.getUi().alert(error.message);
    throw error;
  }
}

function 統計分類數量() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('整理明細');
  const statSheet = ss.getSheetByName('分類統計');

  if (!detailSheet || !statSheet) {
    SpreadsheetApp.getUi().alert('找不到整理明細或分類統計,請先建立通用行政工作台與欄位對照。');
    return;
  }

  const data = detailSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('整理明細沒有資料,請先整理通用明細。');
    return;
  }

  const headers = data[0].map(header => String(header || '').trim());
  const categoryIndex = headers.indexOf('category');
  const quantityIndex = headers.indexOf('quantity');
  if (categoryIndex === -1 || quantityIndex === -1) {
    SpreadsheetApp.getUi().alert('整理明細缺少 category 或 quantity 欄位。');
    return;
  }

  const totals = new Map();
  data.slice(1).forEach((row, index) => {
    const category = String(row[categoryIndex] || '').trim();
    if (!category) return;

    const quantity = Number(row[quantityIndex]);
    if (Number.isNaN(quantity)) {
      throw new Error(`整理明細第 ${index + 2} 列的 quantity 無法加總:${row[quantityIndex]}`);
    }

    totals.set(category, (totals.get(category) || 0) + quantity);
  });

  const output = Array.from(totals.entries()).map(([category, quantity]) => [category, quantity]);
  const total = output.reduce((sum, row) => sum + row[1], 0);
  output.push(['總計', total]);

  statSheet.getRange(2, 1, statSheet.getMaxRows() - 1, 2).clearContent();
  if (output.length) {
    statSheet.getRange(2, 1, output.length, 2).setValues(output);
  }

  SpreadsheetApp.getUi().alert('分類統計完成!');
}

function 產生通用通知文字() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('整理明細');
  const statSheet = ss.getSheetByName('分類統計');
  const noticeSheet = ss.getSheetByName('通知文字');

  if (!detailSheet || !statSheet || !noticeSheet) {
    SpreadsheetApp.getUi().alert('找不到整理明細、分類統計或通知文字,請先建立通用行政工作台與欄位對照。');
    return;
  }

  const settings = 取得系統設定();
  const processName = settings['流程名稱'] || '飲料訂購行政流';
  const noticeTitle = settings['通知標題'] || processName;
  const deadline = settings['截止時間'] || '今天 11:00';

  const detailData = detailSheet.getDataRange().getValues();
  const statData = statSheet.getDataRange().getValues();
  if (detailData.length <= 1) {
    SpreadsheetApp.getUi().alert('整理明細沒有資料,請先整理通用明細。');
    return;
  }

  const detailHeaders = detailData[0].map(header => String(header || '').trim());
  const idx = role => detailHeaders.indexOf(role);
  const statLines = statData.slice(1)
    .filter(row => row[0])
    .map(row => `${row[0]}:${row[1]}`);

  const detailLines = detailData.slice(1)
    .filter(row => row[idx('name')])
    .map(row => {
      const note = row[idx('note')] ? `(${row[idx('note')]})` : '';
      return `${row[idx('name')]}:${row[idx('category')]}/${row[idx('option1')]}/${row[idx('option2')]}/${row[idx('quantity')]}${note}`;
    });

  if (!statLines.length || !detailLines.length) {
    SpreadsheetApp.getUi().alert('目前沒有可輸出的分類統計或整理明細,請先完成整理與統計。');
    return;
  }

  const message = [
    `【${noticeTitle}】`,
    `截止時間:${deadline}`,
    '',
    '分類統計:',
    ...statLines,
    '',
    '明細:',
    ...detailLines,
    '',
    '請確認以上資料是否正確。'
  ].join('\n');

  noticeSheet.getRange(2, 1, noticeSheet.getMaxRows() - 1, 2).clearContent();
  noticeSheet.getRange(2, 1, 1, 2).setValues([[new Date(), message]]);

  SpreadsheetApp.getUi().alert(`「${processName}」通知文字已產生於「通知文字」。`);
}

function 產生行政情境範例對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName('行政情境範例對照');
  if (sheet && sheet.getLastRow() > 1) {
    const ui = SpreadsheetApp.getUi();
    const result = ui.alert(
      '重新建立行政情境範例對照',
      '這個操作會清空目前的行政情境範例對照。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }
  if (!sheet) sheet = ss.insertSheet('行政情境範例對照');
  sheet.clear();

  const rows = [
    ['行政情境', 'name', 'category', 'option1', 'option2', 'quantity', 'note'],
    ['飲料訂購', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'],
    ['社團報名', '學生姓名', '社團志願', '年級', '班級', '報名人數', '特殊需求'],
    ['研習報名', '姓名', '研習場次', '服務單位', '職稱', '報名人數', '研習需求'],
    ['設備借用', '借用人', '設備名稱', '借用日期', '歸還日期', '借用數量', '用途說明'],
    ['成果收件', '繳交人', '成果類型', '班級', '繳交狀態', '件數', '附件連結'],
    ['值勤調查', '姓名', '值勤時段', '可支援日期', '任務類型', '可支援人數', '備註']
  ];

  sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
  sheet.getRange(1, 1, 1, rows[0].length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, rows[0].length);

  SpreadsheetApp.getUi().alert('行政情境範例對照已建立完成!');
}

function 建立欄位索引(headers) {
  const headerIndex = {};
  headers.forEach((header, index) => {
    const name = String(header || '').trim();
    if (name) headerIndex[name] = index;
  });
  return headerIndex;
}

function 取得欄位對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('欄位對照');
  if (!sheet) throw new Error('找不到「欄位對照」,請先建立通用行政工作台與欄位對照。');

  const data = sheet.getDataRange().getValues();
  const fieldMap = {};
  data.slice(1).forEach(row => {
    const role = String(row[0] || '').trim();
    const fieldName = String(row[1] || '').trim();
    const required = String(row[2] || '').trim() === '是';
    const description = String(row[3] || '').trim();
    if (role) fieldMap[role] = { fieldName, required, description };
  });
  return fieldMap;
}

function 依角色取值(row, headerIndex, fieldMap, role) {
  const requiredRoles = ['name', 'category', 'quantity'];
  const config = fieldMap[role];

  if (!config) {
    if (requiredRoles.includes(role)) throw new Error(`欄位對照缺少必要角色:${role}`);
    return '';
  }

  const fieldName = String(config.fieldName || '').trim();
  if (!fieldName) {
    if (config.required) throw new Error(`必要角色 ${role} 沒有設定表單欄位名稱。`);
    return '';
  }

  const index = headerIndex[fieldName];
  if (index === undefined) {
    if (config.required) throw new Error(`必要欄位不存在:欄位對照中的「${fieldName}」找不到對應的表單欄位。`);
    return '';
  }

  const value = row[index];
  return value === null || value === undefined ? '' : value;
}

function 取得系統設定() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('系統設定');
  const settings = {};

  if (!sheet) return settings;

  const data = sheet.getDataRange().getValues();
  data.slice(1).forEach(row => {
    const key = String(row[0] || '').trim();
    const value = row[1];
    if (key && value !== '') settings[key] = value;
  });

  return settings;
}

function 尋找表單回應工作表() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const excludedNames = new Set([
    '訂購明細',
    '品項統計',
    '通知文字',
    '系統設定',
    '整理明細',
    '分類統計',
    '欄位對照',
    '行政情境',
    '行政情境範例對照'
  ]);
  const mappedRequiredHeaders = [];
  const fieldMapSheet = ss.getSheetByName('欄位對照');
  if (fieldMapSheet && fieldMapSheet.getLastRow() > 1) {
    const requiredRoles = new Set(['name', 'category', 'quantity']);
    fieldMapSheet.getDataRange().getDisplayValues().slice(1).forEach(row => {
      const role = String(row[0] || '').trim();
      const fieldName = String(row[1] || '').trim();
      if (requiredRoles.has(role) && fieldName) mappedRequiredHeaders.push(fieldName);
    });
  }

  return ss.getSheets().find(sheet => {
    const name = sheet.getName();
    if (excludedNames.has(name)) return false;
    if (sheet.getLastColumn() < 1) return false;
    const headers = sheet
      .getRange(1, 1, 1, sheet.getLastColumn())
      .getDisplayValues()[0]
      .map(value => String(value).trim());
    const matchesFixedDrinkOrder = (
      headers.includes('時間戳記') &&
      headers.includes('姓名') &&
      headers.includes('飲料品項')
    );
    const matchesFieldMap = (
      headers.includes('時間戳記') &&
      mappedRequiredHeaders.length === 3 &&
      mappedRequiredHeaders.every(header => headers.includes(header))
    );
    return matchesFixedDrinkOrder || matchesFieldMap;
  }) || null;
}
D. 講師或進階使用者版本:雙層整合版完整選單

進階使用

這個版本同時保留固定版與通用版功能,是雙層整合版、完整選單版與系統化擴充版本,適合講師示範、重複開課或後續擴充。

雙層整合版完整 GS 程式碼:保留整堂課完整流程

這個版本包含固定版與通用版,適合講師示範、重複開課或進階使用。貼上後儲存,回到試算表重新整理,會看到「GAS 行政流工具」選單,前半段是飲料訂購固定版,後續進階功能是通用行政整理工具。

雙層整合版
雙層整合版完整 GS
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('GAS 行政流工具')
    .addItem('① 建立飲料訂購表單', '建立飲料訂購表單')
    .addItem('①-1 產生 15 筆模擬訂購資料', '產生模擬訂購資料')
    .addItem('② 建立飲料訂購工作台', '建立飲料訂購工作台')
    .addItem('③ 整理訂購明細', '整理訂購明細')
    .addItem('④ 統計品項數量', '統計品項數量')
    .addItem('⑤ 產生群組通知文字', '產生群組通知文字')
    .addSeparator()
    .addItem('⑥ 建立通用行政工作台與欄位對照', '建立通用行政工作台與欄位對照')
    .addItem('⑦ 整理通用明細', '整理通用明細')
    .addItem('⑧ 統計分類數量', '統計分類數量')
    .addItem('⑨ 產生通用通知文字', '產生通用通知文字')
    .addItem('⑩ 產生行政情境範例對照', '產生行政情境範例對照')
    .addToUi();
}

function 建立飲料訂購表單() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();

  let settingSheet = ss.getSheetByName('系統設定');
  if (!settingSheet) {
    settingSheet = ss.insertSheet('系統設定');
  }
  if (settingSheet.getLastRow() === 0) {
    settingSheet.getRange(1, 1, 1, 2).setValues([['key', 'value']]);
  }

  const settings = {};
  const settingRows = {};
  settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
    const key = String(row[0] || '').trim();
    const value = row[1];
    if (key) {
      settingRows[key] = index + 2;
      if (value !== '') settings[key] = value;
    }
  });

  let existingForm = null;
  if (settings['表單ID']) {
    try {
      existingForm = FormApp.openById(String(settings['表單ID']).trim());
    } catch (error) {
      existingForm = null;
    }
  }
  if (!existingForm && settings['表單編輯網址']) {
    try {
      existingForm = FormApp.openByUrl(String(settings['表單編輯網址']).trim());
    } catch (error) {
      existingForm = null;
    }
  }
  if (!existingForm && settings['表單填寫網址']) {
    try {
      existingForm = FormApp.openByUrl(String(settings['表單填寫網址']).trim());
    } catch (error) {
      existingForm = null;
    }
  }

  if (existingForm) {
    const result = ui.alert(
      '已存在飲料訂購表單',
      '系統設定中已經有有效的表單資料。是否仍要建立一份新的表單?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) {
      ui.alert('已取消建立新表單。');
      return;
    }
  }

  const form = FormApp.create('飲料訂購表單');
  form.setDescription('請填寫飲料訂購資料。這份表單用來練習行政收件:收件、整理、統計與通知。');

  form.addTextItem().setTitle('姓名').setRequired(true);
  form.addListItem().setTitle('飲料品項').setChoiceValues(['紅茶', '綠茶', '奶茶', '拿鐵']).setRequired(true);
  form.addMultipleChoiceItem().setTitle('甜度').setChoiceValues(['正常糖', '半糖', '微糖', '無糖']).setRequired(true);
  form.addMultipleChoiceItem().setTitle('冰塊').setChoiceValues(['正常冰', '少冰', '微冰', '去冰']).setRequired(true);
  form.addListItem().setTitle('數量').setChoiceValues(['1', '2', '3', '4', '5']).setRequired(true);
  form.addParagraphTextItem().setTitle('備註').setRequired(false);

  form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());

  const updates = {
    '流程名稱': settings['流程名稱'] || '飲料訂購行政流',
    '通知模式': settings['通知模式'] || '群組通知',
    '通知標題': settings['通知標題'] || '飲料訂購行政流',
    '店家名稱': settings['店家名稱'] || '今天飲料店',
    '收單時間': settings['收單時間'] || '今天 11:00',
    '截止時間': settings['截止時間'] || settings['收單時間'] || '今天 11:00',
    '表單ID': form.getId(),
    '表單編輯網址': form.getEditUrl(),
    '表單填寫網址': form.getPublishedUrl(),
    '回應試算表ID': ss.getId()
  };

  Object.entries(updates).forEach(([key, value]) => {
    if (settingRows[key]) {
      settingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      settingSheet.appendRow([key, value]);
    }
  });

  ui.alert('飲料訂購表單已建立完成!\n\n填寫網址:\n' + form.getPublishedUrl());
}

function 產生模擬訂購資料() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let responseSheet = 尋找表單回應工作表();

  if (!responseSheet) {
    const excludedNames = new Set([
      '訂購明細',
      '品項統計',
      '通知文字',
      '系統設定',
      '整理明細',
      '分類統計',
      '欄位對照',
      '行政情境',
      '行政情境範例對照'
    ]);
    responseSheet = ss.getSheets().find(sheet => {
      if (excludedNames.has(sheet.getName()) || sheet.getLastColumn() < 1) return false;
      const candidateHeaders = sheet
        .getRange(1, 1, 1, sheet.getLastColumn())
        .getDisplayValues()[0]
        .map(value => String(value).trim());
      const name = sheet.getName();
      const hasCommonResponseName =
        name.startsWith('表單回應') ||
        name.startsWith('表單回覆') ||
        name.startsWith('Form Responses');
      return candidateHeaders.includes('時間戳記') || hasCommonResponseName;
    }) || null;
  }

  if (!responseSheet) {
    responseSheet = ss.insertSheet('表單回應 1');
  }

  const expectedHeaders = ['時間戳記', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'];
  const currentHeaders = responseSheet
    .getRange(1, 1, 1, expectedHeaders.length)
    .getDisplayValues()[0]
    .map(value => String(value).trim());
  const headerIsValid = expectedHeaders.every(
    (header, index) => currentHeaders[index] === header
  );

  if (!headerIsValid) {
    if (responseSheet.getLastRow() > 0) {
      throw new Error(
        '找到的表單回應工作表欄位與課程預期不一致,請確認第一列是否為:時間戳記、姓名、飲料品項、甜度、冰塊、數量、備註。'
      );
    }
    responseSheet.getRange(1, 1, 1, expectedHeaders.length).setValues([expectedHeaders]);
    responseSheet.getRange(1, 1, 1, expectedHeaders.length).setFontWeight('bold');
    responseSheet.setFrozenRows(1);
  }

  const mockRows = [
    ['王小明', '紅茶', '正常糖', '少冰', 1, ''],
    ['陳小華', '奶茶', '半糖', '去冰', 2, '加珍珠'],
    ['林品安', '綠茶', '微糖', '微冰', 1, ''],
    ['黃子晴', '拿鐵', '無糖', '少冰', 1, '不要吸管'],
    ['張育誠', '紅茶', '半糖', '正常冰', 3, ''],
    ['李佳蓉', '奶茶', '微糖', '少冰', 1, ''],
    ['吳柏翰', '綠茶', '無糖', '去冰', 2, ''],
    ['劉怡君', '拿鐵', '正常糖', '正常冰', 1, '加冰塊'],
    ['蔡承恩', '紅茶', '微糖', '去冰', 1, ''],
    ['楊雅婷', '奶茶', '半糖', '微冰', 2, '少甜一點'],
    ['周冠宇', '綠茶', '正常糖', '少冰', 1, ''],
    ['鄭羽庭', '拿鐵', '半糖', '去冰', 1, ''],
    ['許哲維', '紅茶', '無糖', '微冰', 2, ''],
    ['洪詩涵', '奶茶', '正常糖', '少冰', 1, '加椰果'],
    ['郭家豪', '綠茶', '微糖', '正常冰', 3, '']
  ];

  const baseTime = new Date();
  const output = mockRows.map((row, index) => {
    const timestamp = new Date(baseTime.getTime() + index * 60 * 1000);
    return [timestamp, ...row];
  });

  const maxRows = responseSheet.getMaxRows();
  if (maxRows > 1) {
    responseSheet.getRange(2, 1, maxRows - 1, expectedHeaders.length).clearContent();
  }

  responseSheet.getRange(2, 1, output.length, expectedHeaders.length).setValues(output);
  responseSheet.autoResizeColumns(1, expectedHeaders.length);

  SpreadsheetApp.getUi().alert('已產生 15 筆模擬訂購資料,可以繼續執行整理訂購明細。');
}

function 建立飲料訂購工作台() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  const existingSettings = {};
  const settingRows = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key) {
        settingRows[key] = index + 2;
        if (value !== '') existingSettings[key] = value;
      }
    });
  }

  const outputSheetNames = ['訂購明細', '品項統計', '通知文字'];
  const hasOutputData = outputSheetNames.some(name => {
    const sheet = ss.getSheetByName(name);
    return sheet && sheet.getLastRow() > 1;
  });
  if (hasOutputData) {
    const result = ui.alert(
      '重新建立工作台',
      '這個操作會清空訂購明細、品項統計與通知文字,但不會刪除原始表單回應資料。適合課程重新操作或重設使用。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }

  const sheetConfigs = [
    { name: '訂購明細', headers: ['序號', '填寫時間', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'] },
    { name: '品項統計', headers: ['飲料品項', '杯數'] },
    { name: '通知文字', headers: ['產生時間', '通知內容'] },
    { name: '系統設定', headers: ['key', 'value'] }
  ];

  sheetConfigs.forEach(config => {
    let sheet = ss.getSheetByName(config.name);
    if (!sheet) sheet = ss.insertSheet(config.name);
    if (config.name === '系統設定') {
      if (sheet.getLastRow() === 0) {
        sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
        sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
        sheet.setFrozenRows(1);
      }
    } else {
      sheet.clear();
      sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
      sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
      sheet.setFrozenRows(1);
    }
  });

  const updatedSettingSheet = ss.getSheetByName('系統設定');
  const defaultSettings = {
    '流程名稱': existingSettings['流程名稱'] || '飲料訂購行政流',
    '通知模式': existingSettings['通知模式'] || '群組通知',
    '通知標題': existingSettings['通知標題'] || '飲料訂購行政流',
    '店家名稱': existingSettings['店家名稱'] || '今天飲料店',
    '收單時間': existingSettings['收單時間'] || existingSettings['截止時間'] || '今天 11:00',
    '截止時間': existingSettings['截止時間'] || existingSettings['收單時間'] || '今天 11:00',
    '表單ID': existingSettings['表單ID'] || '',
    '表單編輯網址': existingSettings['表單編輯網址'] || '',
    '表單填寫網址': existingSettings['表單填寫網址'] || '',
    '回應試算表ID': existingSettings['回應試算表ID'] || ss.getId()
  };
  Object.entries(defaultSettings).forEach(([key, value]) => {
    if (settingRows[key]) {
      updatedSettingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      updatedSettingSheet.appendRow([key, value]);
    }
  });

  ss.getSheets().forEach(sheet => sheet.autoResizeColumns(1, Math.max(1, sheet.getLastColumn())));
  ui.alert('飲料訂購工作台建立完成!');
}

function 整理訂購明細() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = 尋找表單回應工作表();
  const detailSheet = ss.getSheetByName('訂購明細');

  if (!responseSheet) {
    SpreadsheetApp.getUi().alert('找不到表單回應工作表,請先建立表單並送出測試資料。');
    return;
  }
  if (!detailSheet) {
    SpreadsheetApp.getUi().alert('找不到「訂購明細」,請先執行建立飲料訂購工作台。');
    return;
  }

  const data = responseSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('目前沒有表單回應資料,請先填寫幾筆測試資料。');
    return;
  }

  const rows = data.slice(1);
  const output = rows.map((row, index) => {
    const rawQuantity = row[5];
    const quantity = Number(rawQuantity);
    if (
      rawQuantity === '' ||
      rawQuantity === null ||
      rawQuantity === undefined ||
      Number.isNaN(quantity) ||
      quantity <= 0
    ) {
      throw new Error(`表單回應第 ${index + 2} 列的數量不正確:${rawQuantity}`);
    }

    return [
      index + 1,
      row[0],
      row[1],
      row[2],
      row[3],
      row[4],
      quantity,
      row[6] || ''
    ];
  });

  detailSheet.getRange(2, 1, detailSheet.getMaxRows() - 1, 8).clearContent();
  if (output.length) {
    detailSheet.getRange(2, 1, output.length, output[0].length).setValues(output);
  }
  SpreadsheetApp.getUi().alert('訂購明細整理完成!');
}

function 統計品項數量() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('訂購明細');
  const statSheet = ss.getSheetByName('品項統計');

  if (!detailSheet || !statSheet) {
    SpreadsheetApp.getUi().alert('找不到訂購明細或品項統計,請先建立飲料訂購工作台。');
    return;
  }

  const data = detailSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('訂購明細沒有資料,請先整理訂購明細。');
    return;
  }

  const itemOrder = ['紅茶', '綠茶', '奶茶', '拿鐵'];
  const totals = new Map(itemOrder.map(item => [item, 0]));
  data.slice(1).forEach((row, index) => {
    const item = String(row[3] || '').trim();
    if (!item) return;

    const quantity = Number(row[6]);
    if (Number.isNaN(quantity)) {
      throw new Error(`訂購明細第 ${index + 2} 列的數量無法加總:${row[6]}`);
    }

    totals.set(item, (totals.get(item) || 0) + quantity);
  });

  const output = Array.from(totals.entries()).map(([item, quantity]) => [item, quantity]);
  const total = output.reduce((sum, row) => sum + row[1], 0);
  output.push(['總計', total]);

  statSheet.getRange(2, 1, statSheet.getMaxRows() - 1, 2).clearContent();
  if (output.length) {
    statSheet.getRange(2, 1, output.length, 2).setValues(output);
  }

  SpreadsheetApp.getUi().alert('品項統計完成!');
}

function 產生群組通知文字() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('訂購明細');
  const statSheet = ss.getSheetByName('品項統計');
  const noticeSheet = ss.getSheetByName('通知文字');

  if (!detailSheet || !statSheet || !noticeSheet) {
    SpreadsheetApp.getUi().alert('找不到訂購明細、品項統計或通知文字,請先建立飲料訂購工作台。');
    return;
  }

  const settings = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach(row => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key && value !== '') settings[key] = value;
    });
  }

  const processName = settings['流程名稱'] || '飲料訂購行政流';
  const noticeTitle = settings['通知標題'] || processName;
  const storeName = settings['店家名稱'] || '今天飲料店';
  const closeTime = settings['收單時間'] || settings['截止時間'] || '今天 11:00';

  const detailData = detailSheet.getDataRange().getValues();
  const statData = statSheet.getDataRange().getValues();
  if (detailData.length <= 1) {
    SpreadsheetApp.getUi().alert('訂購明細沒有資料,請先整理訂購明細。');
    return;
  }

  const statLines = statData.slice(1)
    .filter(row => row[0])
    .map(row => `${row[0]}:${row[1]}`);

  const detailLines = detailData.slice(1)
    .filter(row => row[2])
    .map(row => {
      const note = row[7] ? `(${row[7]})` : '';
      return `${row[2]}:${row[3]}/${row[4]}/${row[5]}/${row[6]} 杯${note}`;
    });

  if (!statLines.length || !detailLines.length) {
    SpreadsheetApp.getUi().alert('目前沒有可輸出的統計或訂購明細,請先完成整理與統計。');
    return;
  }

  const message = [
    `【${noticeTitle}】`,
    `店家名稱:${storeName}`,
    `收單時間:${closeTime}`,
    '',
    '品項統計:',
    ...statLines,
    '',
    '訂購明細:',
    ...detailLines,
    '',
    '確認提醒:請確認姓名、飲料、甜度、冰塊與數量是否正確。'
  ].join('\n');

  noticeSheet.getRange(2, 1, noticeSheet.getMaxRows() - 1, 2).clearContent();
  noticeSheet.getRange(2, 1, 1, 2).setValues([[new Date(), message]]);

  SpreadsheetApp.getUi().alert(`「${processName}」群組通知文字已產生於「通知文字」。`);
}

function 建立通用行政工作台與欄位對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();
  const existingSettings = {};
  const settingRows = {};
  const settingSheet = ss.getSheetByName('系統設定');
  if (settingSheet) {
    settingSheet.getDataRange().getValues().slice(1).forEach((row, index) => {
      const key = String(row[0] || '').trim();
      const value = row[1];
      if (key) {
        settingRows[key] = index + 2;
        if (value !== '') existingSettings[key] = value;
      }
    });
  }

  const outputSheetNames = ['整理明細', '分類統計', '通知文字', '欄位對照'];
  const hasOutputData = outputSheetNames.some(name => {
    const sheet = ss.getSheetByName(name);
    return sheet && sheet.getLastRow() > 1;
  });
  if (hasOutputData) {
    const result = ui.alert(
      '重新建立通用工作台',
      '這個操作會清空整理明細、分類統計、通知文字與欄位對照,但不會刪除原始表單回應資料。適合課程重新操作或重設使用。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }

  const sheetConfigs = [
    { name: '整理明細', headers: ['序號', '填寫時間', 'name', 'category', 'option1', 'option2', 'quantity', 'note'] },
    { name: '分類統計', headers: ['分類', '數量'] },
    { name: '通知文字', headers: ['產生時間', '通知內容'] },
    { name: '系統設定', headers: ['key', 'value'] },
    { name: '欄位對照', headers: ['後端角色', '表單欄位名稱', '是否必要', '說明'] }
  ];

  sheetConfigs.forEach(config => {
    let sheet = ss.getSheetByName(config.name);
    if (!sheet) sheet = ss.insertSheet(config.name);
    if (config.name === '系統設定') {
      if (sheet.getLastRow() === 0) {
        sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
        sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
        sheet.setFrozenRows(1);
      }
    } else {
      sheet.clear();
      sheet.getRange(1, 1, 1, config.headers.length).setValues([config.headers]);
      sheet.getRange(1, 1, 1, config.headers.length).setFontWeight('bold');
      sheet.setFrozenRows(1);
    }
  });

  const updatedSettingSheet = ss.getSheetByName('系統設定');
  const defaultSettings = {
    '流程名稱': existingSettings['流程名稱'] || '飲料訂購行政流',
    '通知模式': existingSettings['通知模式'] || '群組通知',
    '通知標題': existingSettings['通知標題'] || '飲料訂購行政流',
    '店家名稱': existingSettings['店家名稱'] || '今天飲料店',
    '收單時間': existingSettings['收單時間'] || existingSettings['截止時間'] || '今天 11:00',
    '截止時間': existingSettings['截止時間'] || existingSettings['收單時間'] || '今天 11:00',
    '表單ID': existingSettings['表單ID'] || '',
    '表單編輯網址': existingSettings['表單編輯網址'] || '',
    '表單填寫網址': existingSettings['表單填寫網址'] || '',
    '回應試算表ID': existingSettings['回應試算表ID'] || ss.getId()
  };
  Object.entries(defaultSettings).forEach(([key, value]) => {
    if (settingRows[key]) {
      updatedSettingSheet.getRange(settingRows[key], 2).setValue(value);
    } else {
      updatedSettingSheet.appendRow([key, value]);
    }
  });

  ss.getSheetByName('欄位對照').getRange(2, 1, 6, 4).setValues([
    ['name', '姓名', '是', '訂購者、報名者或申請人'],
    ['category', '飲料品項', '是', '要統計或分類的主要欄位'],
    ['option1', '甜度', '否', '補充條件一'],
    ['option2', '冰塊', '否', '補充條件二'],
    ['quantity', '數量', '是', '可加總的數字欄位'],
    ['note', '備註', '否', '補充說明、特殊需求或附件連結']
  ]);

  ss.getSheets().forEach(sheet => sheet.autoResizeColumns(1, Math.max(1, sheet.getLastColumn())));
  ui.alert('通用行政工作台與欄位對照建立完成!');
}

function 整理通用明細() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = 尋找表單回應工作表();
  const detailSheet = ss.getSheetByName('整理明細');

  if (!responseSheet) {
    SpreadsheetApp.getUi().alert('找不到表單回應工作表,請先建立表單並送出測試資料。');
    return;
  }
  if (!detailSheet) {
    SpreadsheetApp.getUi().alert('找不到「整理明細」,請先執行建立通用行政工作台與欄位對照。');
    return;
  }

  const data = responseSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('目前沒有表單回應資料,請先填寫幾筆測試資料。');
    return;
  }

  const headers = data[0];
  const headerIndex = 建立欄位索引(headers);
  const fieldMap = 取得欄位對照();
  const rows = data.slice(1);

  try {
    const output = rows.map((row, index) => {
      const rawQuantity = 依角色取值(row, headerIndex, fieldMap, 'quantity');
      const quantity = Number(rawQuantity);
      if (
        rawQuantity === '' ||
        rawQuantity === null ||
        rawQuantity === undefined ||
        Number.isNaN(quantity) ||
        quantity <= 0
      ) {
        throw new Error(`表單回應第 ${index + 2} 列的數量不正確:${rawQuantity}`);
      }

      return [
        index + 1,
        row[0],
        依角色取值(row, headerIndex, fieldMap, 'name'),
        依角色取值(row, headerIndex, fieldMap, 'category'),
        依角色取值(row, headerIndex, fieldMap, 'option1'),
        依角色取值(row, headerIndex, fieldMap, 'option2'),
        quantity,
        依角色取值(row, headerIndex, fieldMap, 'note')
      ];
    });

    detailSheet.getRange(2, 1, detailSheet.getMaxRows() - 1, 8).clearContent();
    detailSheet.getRange(2, 1, output.length, output[0].length).setValues(output);
    SpreadsheetApp.getUi().alert('通用明細整理完成!');
  } catch (error) {
    SpreadsheetApp.getUi().alert(error.message);
    throw error;
  }
}

function 統計分類數量() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('整理明細');
  const statSheet = ss.getSheetByName('分類統計');

  if (!detailSheet || !statSheet) {
    SpreadsheetApp.getUi().alert('找不到整理明細或分類統計,請先建立通用行政工作台與欄位對照。');
    return;
  }

  const data = detailSheet.getDataRange().getValues();
  if (data.length <= 1) {
    SpreadsheetApp.getUi().alert('整理明細沒有資料,請先整理通用明細。');
    return;
  }

  const headers = data[0].map(header => String(header || '').trim());
  const categoryIndex = headers.indexOf('category');
  const quantityIndex = headers.indexOf('quantity');
  if (categoryIndex === -1 || quantityIndex === -1) {
    SpreadsheetApp.getUi().alert('整理明細缺少 category 或 quantity 欄位。');
    return;
  }

  const totals = new Map();
  data.slice(1).forEach((row, index) => {
    const category = String(row[categoryIndex] || '').trim();
    if (!category) return;

    const quantity = Number(row[quantityIndex]);
    if (Number.isNaN(quantity)) {
      throw new Error(`整理明細第 ${index + 2} 列的 quantity 無法加總:${row[quantityIndex]}`);
    }

    totals.set(category, (totals.get(category) || 0) + quantity);
  });

  const output = Array.from(totals.entries()).map(([category, quantity]) => [category, quantity]);
  const total = output.reduce((sum, row) => sum + row[1], 0);
  output.push(['總計', total]);

  statSheet.getRange(2, 1, statSheet.getMaxRows() - 1, 2).clearContent();
  if (output.length) {
    statSheet.getRange(2, 1, output.length, 2).setValues(output);
  }

  SpreadsheetApp.getUi().alert('分類統計完成!');
}

function 產生通用通知文字() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const detailSheet = ss.getSheetByName('整理明細');
  const statSheet = ss.getSheetByName('分類統計');
  const noticeSheet = ss.getSheetByName('通知文字');

  if (!detailSheet || !statSheet || !noticeSheet) {
    SpreadsheetApp.getUi().alert('找不到整理明細、分類統計或通知文字,請先建立通用行政工作台與欄位對照。');
    return;
  }

  const settings = 取得系統設定();
  const processName = settings['流程名稱'] || '飲料訂購行政流';
  const noticeTitle = settings['通知標題'] || processName;
  const deadline = settings['截止時間'] || '今天 11:00';

  const detailData = detailSheet.getDataRange().getValues();
  const statData = statSheet.getDataRange().getValues();
  if (detailData.length <= 1) {
    SpreadsheetApp.getUi().alert('整理明細沒有資料,請先整理通用明細。');
    return;
  }

  const detailHeaders = detailData[0].map(header => String(header || '').trim());
  const idx = role => detailHeaders.indexOf(role);
  const statLines = statData.slice(1)
    .filter(row => row[0])
    .map(row => `${row[0]}:${row[1]}`);

  const detailLines = detailData.slice(1)
    .filter(row => row[idx('name')])
    .map(row => {
      const note = row[idx('note')] ? `(${row[idx('note')]})` : '';
      return `${row[idx('name')]}:${row[idx('category')]}/${row[idx('option1')]}/${row[idx('option2')]}/${row[idx('quantity')]}${note}`;
    });

  if (!statLines.length || !detailLines.length) {
    SpreadsheetApp.getUi().alert('目前沒有可輸出的分類統計或整理明細,請先完成整理與統計。');
    return;
  }

  const message = [
    `【${noticeTitle}】`,
    `截止時間:${deadline}`,
    '',
    '分類統計:',
    ...statLines,
    '',
    '明細:',
    ...detailLines,
    '',
    '請確認以上資料是否正確。'
  ].join('\n');

  noticeSheet.getRange(2, 1, noticeSheet.getMaxRows() - 1, 2).clearContent();
  noticeSheet.getRange(2, 1, 1, 2).setValues([[new Date(), message]]);

  SpreadsheetApp.getUi().alert(`「${processName}」通知文字已產生於「通知文字」。`);
}

function 產生行政情境範例對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName('行政情境範例對照');
  if (sheet && sheet.getLastRow() > 1) {
    const ui = SpreadsheetApp.getUi();
    const result = ui.alert(
      '重新建立行政情境範例對照',
      '這個操作會清空目前的行政情境範例對照。是否繼續?',
      ui.ButtonSet.YES_NO
    );
    if (result !== ui.Button.YES) return;
  }
  if (!sheet) sheet = ss.insertSheet('行政情境範例對照');
  sheet.clear();

  const rows = [
    ['行政情境', 'name', 'category', 'option1', 'option2', 'quantity', 'note'],
    ['飲料訂購', '姓名', '飲料品項', '甜度', '冰塊', '數量', '備註'],
    ['社團報名', '學生姓名', '社團志願', '年級', '班級', '報名人數', '特殊需求'],
    ['研習報名', '姓名', '研習場次', '服務單位', '職稱', '報名人數', '研習需求'],
    ['設備借用', '借用人', '設備名稱', '借用日期', '歸還日期', '借用數量', '用途說明'],
    ['成果收件', '繳交人', '成果類型', '班級', '繳交狀態', '件數', '附件連結'],
    ['值勤調查', '姓名', '值勤時段', '可支援日期', '任務類型', '可支援人數', '備註']
  ];

  sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
  sheet.getRange(1, 1, 1, rows[0].length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  sheet.autoResizeColumns(1, rows[0].length);

  SpreadsheetApp.getUi().alert('行政情境範例對照已建立完成!');
}

function 建立欄位索引(headers) {
  const headerIndex = {};
  headers.forEach((header, index) => {
    const name = String(header || '').trim();
    if (name) headerIndex[name] = index;
  });
  return headerIndex;
}

function 取得欄位對照() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('欄位對照');
  if (!sheet) throw new Error('找不到「欄位對照」,請先建立通用行政工作台與欄位對照。');

  const data = sheet.getDataRange().getValues();
  const fieldMap = {};
  data.slice(1).forEach(row => {
    const role = String(row[0] || '').trim();
    const fieldName = String(row[1] || '').trim();
    const required = String(row[2] || '').trim() === '是';
    const description = String(row[3] || '').trim();
    if (role) fieldMap[role] = { fieldName, required, description };
  });
  return fieldMap;
}

function 依角色取值(row, headerIndex, fieldMap, role) {
  const requiredRoles = ['name', 'category', 'quantity'];
  const config = fieldMap[role];

  if (!config) {
    if (requiredRoles.includes(role)) throw new Error(`欄位對照缺少必要角色:${role}`);
    return '';
  }

  const fieldName = String(config.fieldName || '').trim();
  if (!fieldName) {
    if (config.required) throw new Error(`必要角色 ${role} 沒有設定表單欄位名稱。`);
    return '';
  }

  const index = headerIndex[fieldName];
  if (index === undefined) {
    if (config.required) throw new Error(`必要欄位不存在:欄位對照中的「${fieldName}」找不到對應的表單欄位。`);
    return '';
  }

  const value = row[index];
  return value === null || value === undefined ? '' : value;
}

function 取得系統設定() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('系統設定');
  const settings = {};

  if (!sheet) return settings;

  const data = sheet.getDataRange().getValues();
  data.slice(1).forEach(row => {
    const key = String(row[0] || '').trim();
    const value = row[1];
    if (key && value !== '') settings[key] = value;
  });

  return settings;
}

function 尋找表單回應工作表() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const excludedNames = new Set([
    '訂購明細',
    '品項統計',
    '通知文字',
    '系統設定',
    '整理明細',
    '分類統計',
    '欄位對照',
    '行政情境',
    '行政情境範例對照'
  ]);
  const mappedRequiredHeaders = [];
  const fieldMapSheet = ss.getSheetByName('欄位對照');
  if (fieldMapSheet && fieldMapSheet.getLastRow() > 1) {
    const requiredRoles = new Set(['name', 'category', 'quantity']);
    fieldMapSheet.getDataRange().getDisplayValues().slice(1).forEach(row => {
      const role = String(row[0] || '').trim();
      const fieldName = String(row[1] || '').trim();
      if (requiredRoles.has(role) && fieldName) mappedRequiredHeaders.push(fieldName);
    });
  }

  return ss.getSheets().find(sheet => {
    const name = sheet.getName();
    if (excludedNames.has(name)) return false;
    if (sheet.getLastColumn() < 1) return false;
    const headers = sheet
      .getRange(1, 1, 1, sheet.getLastColumn())
      .getDisplayValues()[0]
      .map(value => String(value).trim());
    const matchesFixedDrinkOrder = (
      headers.includes('時間戳記') &&
      headers.includes('姓名') &&
      headers.includes('飲料品項')
    );
    const matchesFieldMap = (
      headers.includes('時間戳記') &&
      mappedRequiredHeaders.length === 3 &&
      mappedRequiredHeaders.every(header => headers.includes(header))
    );
    return matchesFixedDrinkOrder || matchesFieldMap;
  }) || null;
}

24自我檢查

固定版成果檢查

  • 是否成功建立飲料訂購表單。
  • 是否已親自送出一筆表單資料,並執行「產生 15 筆模擬訂購資料」。
  • 是否能看到表單回應工作表中有 15 筆模擬資料。
  • 是否能建立飲料訂購工作台。
  • 是否能整理訂購明細。
  • 是否能統計品項數量。
  • 是否能產生群組通知文字。
  • 是否能使用模擬資料完成整理、統計與通知。
  • 是否知道卡住時可以使用課堂保底版重新加入進度。

通用版理解檢查

  • 是否看得懂欄位對照的用途。
  • 是否知道 namecategoryquantity 的意義。
  • 是否能說明 row[1] 固定欄位版的限制。
  • 是否能把 category 從飲料品項改成社團志願。
  • 是否知道表單欄位順序改變時,要檢查欄位對照。
  • 是否知道 GAS 07、GAS 08、GAS 09 是課後進階,不是課堂必做。

25常見錯誤排除

固定版常見錯誤

問題 可能原因 處理方式
執行時要求授權第一次使用 Apps Script 的正常流程。使用自己的帳號練習時,依畫面提示完成授權。
找不到表單回應工作表表單尚未產生回應工作表,或第一列缺少時間戳記、姓名、飲料品項。先送出一筆測試資料,再確認第一列表頭。程式以欄位標題辨識,不需要修改工作表名稱。
訂購明細沒有資料還沒有表單回應資料,或尚未執行「整理訂購明細」。先親自送出 1 筆資料,再執行「①-1 產生 15 筆模擬訂購資料」,接著執行 GAS 03。
沒有學員填表,後面無法整理資料線上研習時來不及互相填寫表單,或表單網址沒有成功分享。執行「①-1 產生 15 筆模擬訂購資料」,先建立可供整理與統計的測試資料。
產生模擬資料後,整理結果不正確表單回應工作表的欄位順序與固定版程式預期不同。確認表單回應工作表第一列順序為:時間戳記、姓名、飲料品項、甜度、冰塊、數量、備註。若欄位已被調整,請改用通用版欄位對照流程。
品項統計都是 0數量欄位沒有數字,或表單欄位順序已被改動。確認表單「數量」仍在固定版預期的第 6 欄,並重新整理訂購明細。
通知文字沒有內容還沒有訂購明細或品項統計。依序執行「整理訂購明細」與「統計品項數量」。
甜度或冰塊沒有出現在通知文字中表單欄位順序被調整,或測試資料缺少甜度、冰塊。確認甜度是第 4 欄、冰塊是第 5 欄,並重新整理訂購明細後再產生通知。

通用版常見錯誤

問題 可能原因 處理方式
欄位對照找不到表單欄位欄位對照表中的「表單欄位名稱」與表單回應第一列表頭不一致。確認欄位名稱完全相同,或更新欄位對照。
必要欄位不存在namecategoryquantity 沒有對應到實際表單欄位。檢查欄位對照,必要欄位請填「是」並填入正確表單欄位名稱。
數量欄位無法加總quantity 對應到文字欄位,或填答內容不是數字。把表單欄位改成數字或選單,並確認欄位對照指向正確欄位。
使用者改了表單欄位名稱但沒有更新欄位對照後端角色仍指向舊欄位名稱。到「欄位對照」把表單欄位名稱改成新名稱。
表單回應工作表欄位名稱前後有空白表頭多了空白,導致欄位名稱看起來一樣但對不到。本版程式會對表頭與欄位對照做 trim(),仍建議手動移除多餘空白。

26可以改造成哪些行政流程

以下是課後延伸與進階應用。同一套欄位角色可以換成不同情境;課堂中只需要完成一項簡單改造,建議先用「飲料訂購」改成「社團報名」練習。

飲料訂購

category 是飲料品項,quantity 是數量。

社團報名

category 是社團志願,option1 可放年級,option2 可放班級。

研習報名

category 是研習場次,option1 可放服務單位或職稱。

設備借用

category 是設備名稱,quantity 是借用數量。

成果收件

category 是成果類型,note 可放附件連結。

值勤調查

category 是值勤時段或任務,quantity 可放可支援人數。

27結語

GAS 不是只是在寫程式,而是在把行政工作中的資訊流固定下來。表單負責收件,試算表負責承接資料,GAS 負責整理、統計與輸出。

固定版先讓成果可被使用,通用化示範再讓流程可被改造。從小流程開始,先穩定輸出名冊、統計與通知文字,再逐步延伸到更多行政情境。