iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0

在實作完核心預約與 AI / Telegram 整合後,今天進入 模組 C:取消、改期與規章執行層——CancellationService.gs。

本模組嚴格遵循訂定的 SRD 業務規章:

  1. ≥ 24小時取消規則:預約開始時間24小時前取消,狀態記為 CANCELLED,不收取任何費用 / 但須在60日內重新預訂。
  2. < 24小時晚取消規則:預約開始時間24小時內取消,狀態記為 LATE_CANCELLED,視為已使用時段並強制收取全額費用。
  3. 改期 (Reschedule) 邏輯:採「原預約取消 + 新預約建立」的原子性操作流程,同樣適用24小時規章檢測與時間衝突排查。

1. 業務邏輯與規章判斷流程

[職工提交取消請求]
        │
        ▼
計算 ΔT = (預約開始時間 - 當前時間)
        │
   ┌────┴─────────────────────────┐
   ▼                              ▼
ΔT ≥ 24 小時                    ΔT < 24 小時
   │                              │
[狀態: CANCELLED]               [狀態: LATE_CANCELLED]
[費用: HKD $0]                  [費用: 照常全額計費]
   │                              │
   └──────────────┬───────────────┘
                  ▼
       更新 Google Sheet 狀態
                  │
                  ▼
       發送 Telegram 警示通知


2. 後端核心程式碼實作 (CancellationService.gs)

請在 Google Apps Script 專案中新增名為 CancellationService.gs 的檔案,並寫入以下程式碼:

/**
 * ==============================================================================
 * 取消、改期與 24 小時規章 API (CancellationService.gs)
 * 專案:NGO 房間與資源預約管理系統 (Build on Google AI Track)
 * 說明:實作預約取消規章檢測 (24HR 門檻)、改期邏輯,並同步推播 Telegram 警示通知。
 * ==============================================================================
 */

/**
 * 執行預約取消 (套用 24 小時免費/罰則規章)
 * @param {string} bookingId - 預約單號 (e.g. "BK_1700000000000")
 * @param {string} userEmail - 申請取消的職工 Email
 * @param {string} reason - 取消原因
 * @returns {Object} 處理結果與規章判定狀態
 */
function cancelBooking(bookingId, userEmail, reason) {
  const config = getConfig();
  const spreadsheet = SpreadsheetApp.openById(config.SPREADSHEET_ID);
  const sheet = spreadsheet.getSheetByName("Bookings");

  if (!sheet) {
    throw new Error("❌ 資料庫找不到 Bookings 工作表。");
  }

  const data = sheet.getDataRange().getValues();
  const headers = data.shift() || [];

  const colIndex = {
    bookingId: headers.indexOf("booking_id"),
    roomId: headers.indexOf("room_id"),
    userEmail: headers.indexOf("user_email"),
    startTime: headers.indexOf("start_time"),
    endTime: headers.indexOf("end_time"),
    status: headers.indexOf("status"),
    adminNote: headers.indexOf("admin_note")
  };

  // 1. 尋找對應的預約紀錄
  let rowIndex = -1;
  let bookingRow = null;

  for (let i = 0; i < data.length; i++) {
    if (data[i][colIndex.bookingId] === bookingId) {
      rowIndex = i + 2; // 加 2 補回 Header 列與 1-based index
      bookingRow = data[i];
      break;
    }
  }

  if (!bookingRow) {
    return { success: false, message: `❌ 找不到預約編號:${bookingId}` };
  }

  // 2. 權限與狀態驗證
  if (bookingRow[colIndex.userEmail] !== userEmail) {
    return { success: false, message: "❌ 權限不足:您無權取消不屬於您的預約。" };
  }

  const currentStatus = bookingRow[colIndex.status];
  if (currentStatus === "CANCELLED" || currentStatus === "LATE_CANCELLED") {
    return { success: false, message: "⚠️ 該預約先前已經辦理取消,請勿重複操作。" };
  }

  // 3. 24 小時規章時間差計算
  const now = new Date();
  const startTime = new Date(bookingRow[colIndex.startTime]);
  const endTime = new Date(bookingRow[colIndex.endTime]);
  const diffHours = (startTime.getTime() - now.getTime()) / (1000 * 60 * 60);

  let newStatus = "";
  let feePenaltyText = "";
  let isLateCancel = false;

  if (diffHours >= config.CANCELLATION_FREE_HOURS) {
    // ≥ 24 小時:免費取消
    newStatus = "CANCELLED";
    feePenaltyText = "免費取消 (全額免扣款/退款)";
  } else {
    // < 24 小時:晚取消罰則 (照常全額扣款)
    newStatus = "LATE_CANCELLED";
    feePenaltyText = `⚠️ 距離預約不足 ${config.CANCELLATION_FREE_HOURS} 小時,依規章實施晚取消 (LATE_CANCELLED),費用照常收取。`;
    isLateCancel = true;
  }

  // 4. 計算原本租用費用
  const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
  const totalSlots = durationMinutes / config.SLOT_DURATION_MINUTES;
  const originalFee = totalSlots * config.FEE_PER_SLOT;
  const finalFee = isLateCancel ? originalFee : 0;

  // 5. 更新 Sheet 狀態與 Admin Note
  const updatedNote = `[取消原因: ${reason || "無"}] | 異動時間: ${Utilities.formatDate(now, "Asia/Hong_Kong", "yyyy-MM-dd HH:mm")}`;
  
  sheet.getRange(rowIndex, colIndex.status + 1).setValue(newStatus);
  sheet.getRange(rowIndex, colIndex.adminNote + 1).setValue(updatedNote);

  // 6. 發送 Telegram 警示/異動通知
  const formattedStart = Utilities.formatDate(startTime, "Asia/Hong_Kong", "yyyy-MM-dd HH:mm");
  const roomId = bookingRow[colIndex.roomId];

  const telegramMsg = 
    `🚨 <b>【房間預約異動 - 取消通知】</b>\n` +
    `--------------------------------------\n` +
    `🆔 <b>預約編號:</b><code>${bookingId}</code>\n` +
    `🏢 <b>預約房間:</b>${roomId}\n` +
    `👤 <b>申請職工:</b>${userEmail}\n` +
    `⏰ <b>原預約時段:</b>${formattedStart}\n` +
    `📌 <b>最終取消狀態:</b>${newStatus}\n` +
    `💰 <b>費用結算:</b>HKD $${finalFee} (${feePenaltyText})\n` +
    `💬 <b>取消原因:</b>${reason || "未提供"}\n` +
    `--------------------------------------\n` +
    `<i>系統已依據 24HR 規章更新資料庫。</i>`;

  sendTelegramNotification(telegramMsg);

  return {
    success: true,
    bookingId: bookingId,
    status: newStatus,
    isLateCancel: isLateCancel,
    finalFee: finalFee,
    message: `✅ 預約取消成功。${feePenaltyText}`
  };
}

/**
 * 執行預約改期 (Reschedule)
 * @param {string} bookingId - 原預約單號
 * @param {string} userEmail - 職工 Email
 * @param {string} newStartTimeIso - 新預約開始時間 (ISO)
 * @param {string} newEndTimeIso - 新預約結束時間 (ISO)
 * @param {string} reason - 改期原因說明
 * @returns {Object} 處理結果
 */
function rescheduleBooking(bookingId, userEmail, newStartTimeIso, newEndTimeIso, reason) {
  // 1. 驗證原預約是否存在且可改期
  const cancelResult = cancelBooking(bookingId, userEmail, `改期至新時段 (${reason || "職工自行改期"})`);

  if (!cancelResult.success) {
    return { success: false, message: `❌ 改期失敗:原預約無法取消。(${cancelResult.message})` };
  }

  // ⚠️ 警示:若屬於晚取消,提醒費用原則
  let lateWarning = "";
  if (cancelResult.isLateCancel) {
    lateWarning = " (注意:原預約因不足 24HR 已列為 LATE_CANCELLED 計費)";
  }

  // 2. 取取消紀錄對應房間,並嘗試建立新預約
  const config = getConfig();
  const sheet = SpreadsheetApp.openById(config.SPREADSHEET_ID).getSheetByName("Bookings");
  const data = sheet.getDataRange().getValues();
  const headers = data.shift() || [];
  
  const colIndex = {
    bookingId: headers.indexOf("booking_id"),
    roomId: headers.indexOf("room_id")
  };

  let roomId = "";
  for (let row of data) {
    if (row[colIndex.bookingId] === bookingId) {
      roomId = row[colIndex.roomId];
      break;
    }
  }

  // 3. 提交新時段預約
  const newBookingResult = submitNewBooking(
    userEmail, 
    roomId, 
    newStartTimeIso, 
    newEndTimeIso, 
    `[改期自 ${bookingId}] ${reason}`
  );

  if (!newBookingResult.success) {
    return { 
      success: false, 
      message: `⚠️ 原預約已辦理取消,但新時段建立失敗:${newBookingResult.message}` 
    };
  }

  return {
    success: true,
    oldBookingId: bookingId,
    newBookingId: newBookingResult.bookingId,
    message: `✅ 成功改期!新預約單號:${newBookingResult.bookingId}${lateWarning}`
  };
}


3. 單元測試與規章驗證 (testCancellationFlow)

你可以在 Apps Script 中執行下述測試,分別測試 ≥ 24小時規範 與 < 24小時罰則 運作:

/**
 * Day 20 取消與規章測試
 */
function testCancellationFlow() {
  Logger.log("=== 開始 Day 20 預約取消與規章測試 ===");

  const userEmail = "worker@ngo.org";
  const roomId = "TALK_ROOM";

  // 1. 建立一個 3 天後的預約 (測試免費取消)
  const futureDate = new Date();
  futureDate.setDate(futureDate.getDate() + 3);
  const dateStr1 = Utilities.formatDate(futureDate, "Asia/Hong_Kong", "yyyy-MM-dd");

  const b1 = submitNewBooking(userEmail, roomId, `${dateStr1}T14:00:00.000Z`, `${dateStr1}T15:00:00.000Z`, "未來個案面談");
  Logger.log("建立測試預約 1: " + b1.bookingId);

  // 執行 ≥ 24HR 免費取消
  const res1 = cancelBooking(b1.bookingId, userEmail, "活動取消,提前辦理退租");
  Logger.log("測試 1 結果 (應為 CANCELLED, 費用 0): " + JSON.stringify(res1));

  // 2. 建立一個 5 小時後的預約 (測試晚取消罰則)
  const soonDate = new Date();
  soonDate.setHours(soonDate.getHours() + 5);
  const soonStartIso = soonDate.toISOString();
  soonDate.setHours(soonDate.getHours() + 1);
  const soonEndIso = soonDate.toISOString();

  const b2 = submitNewBooking(userEmail, roomId, soonStartIso, soonEndIso, "臨時緊急小組會議");
  Logger.log("\n建立測試預約 2: " + b2.bookingId);

  // 執行 < 24HR 晚取消
  const res2 = cancelBooking(b2.bookingId, userEmail, "職工臨時生病無法出席");
  Logger.log("測試 2 結果 (應為 LATE_CANCELLED, 照常扣款): " + JSON.stringify(res2));
}


4. 今日成果與 Telegram 訊息範例

執行測試後,你的 Telegram 群組將會即時收到如下格式的推播:

🚨 【房間預約異動 - 取消通知】

🆔 預約編號: BK_1700000000000

🏢 預約房間: TALK_ROOM
👤 申請職工: worker@ngo.org
⏰ 原預約時段: 2026-10-05 14:00
📌 最終取消狀態: LATE_CANCELLED
💰 費用結算: HKD $100 (⚠️ 距離預約不足 24 小時,依規章實施晚取消 (LATE_CANCELLED),費用照常收取。)
💬 取消原因: 職工臨時生病無法出席
系統已依據 24HR 規章更新資料庫。


上一篇
【Day 19】後端核心預約邏輯 API (BookingService.gs) 設計與實作
下一篇
【Day 21】前端 FullCalendar 日曆整合與 Web App 介面
系列文
零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言