iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Build on Google AI

零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統系列 第 19 篇

【Day 19】後端核心預約邏輯 API (BookingService.gs) 設計與實作

  • 分享至 

  • xImage
  •  

在完成了 Config.gs 全局設定檔與 Telegram Bot API 連線測試後,今天我們進入系統後端最核心的業務邏輯層——BookingService.gs (預約服務模組)。

本模組嚴格遵循昨日訂定的 SRD (系統需求說明書) 規格,實作預約時段查詢、時間衝突檢測算法(Interval Overlap Detection)、新預約建立與 Telegram 即時推播通知。


1. 核心業務邏輯與衝突檢測算法

在處理房間與資源預約時,最關鍵的要求是「嚴防重覆預約 (Double Booking)」。

區間重疊算法 (Interval Overlap)

兩個時間區間 A = [StartA, EndA) 與 B = [StartB, EndB) 發生時間重疊的充要條件為:

Overlap = (StartA < EndB) ^ (EndA > StartB)

只要資料庫中存在任意一筆狀態為 APPROVED 的預約紀錄符合上述條件,該預約請求即視為時間衝突,系統將拒絕寫入並回傳錯誤訊息。


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

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

/**
 * ==============================================================================
 * 後端核心預約邏輯 API (BookingService.gs)
 * 專案:NGO 房間與資源預約管理系統
 * 說明:實作可預約時段查詢、時間衝突檢測算法、提交新預約與 Telegram 推播整合。
 * ==============================================================================
 */

/**
 * 取得指定房間在特定日期的所有可用與已預約時段清單
 * @param {string} dateStr - 查詢日期 (格式: YYYY-MM-DD)
 * @param {string} roomId - 房間代號 (例如: "A1", "TALK_ROOM")
 * @returns {Object} 包含當日所有時段狀態與元資料
 */
function getAvailableSlots(dateStr, roomId) {
  const config = getConfig();
  const spreadsheet = SpreadsheetApp.openById(config.SPREADSHEET_ID);
  const sheet = spreadsheet.getSheetByName("Bookings");
  
  if (!sheet) {
    throw new Error("❌ 資料庫找不到 Bookings 工作表,請檢查 Sheet 設定。");
  }

  // 1. 產生當天營業時間內的所有 30 分鐘時間區間
  const allSlots = generateDaySlots(dateStr, config.WORK_START_TIME, config.WORK_END_TIME, config.SLOT_DURATION_MINUTES);
  
  // 2. 取得資料庫中該房間且未取消的預約紀錄
  const data = sheet.getDataRange().getValues();
  const headers = data.shift() || [];
  
  const colIndex = {
    roomId: headers.indexOf("room_id"),
    startTime: headers.indexOf("start_time"),
    endTime: headers.indexOf("end_time"),
    status: headers.indexOf("status")
  };

  const existingBookings = data.filter(row => {
    const status = row[colIndex.status];
    const rId = row[colIndex.roomId];
    const isSameRoom = (rId === roomId);
    const isActiveStatus = (status === "APPROVED");
    
    // 判斷是否為同日預約
    const bookingDateStr = Utilities.formatDate(new Date(row[colIndex.startTime]), "Asia/Hong_Kong", "yyyy-MM-dd");
    const isSameDate = (bookingDateStr === dateStr);

    return isSameRoom && isActiveStatus && isSameDate;
  }).map(row => ({
    startTime: new Date(row[colIndex.startTime]).getTime(),
    endTime: new Date(row[colIndex.endTime]).getTime()
  }));

  // 3. 標記各個時段是否已被預約
  const processedSlots = allSlots.map(slot => {
    const slotStart = slot.startTime.getTime();
    const slotEnd = slot.endTime.getTime();
    
    // 檢查是否有任何預約與此 Slot 重疊
    const isBooked = existingBookings.some(b => (slotStart < b.endTime && slotEnd > b.startTime));
    
    return {
      timeLabel: slot.timeLabel, // e.g. "09:00 - 09:30"
      startTimeIso: slot.startTime.toISOString(),
      endTimeIso: slot.endTime.toISOString(),
      isAvailable: !isBooked
    };
  });

  return {
    success: true,
    date: dateStr,
    roomId: roomId,
    slots: processedSlots
  };
}

/**
 * 提交新預約 (包含 90 天極限驗證、時間衝突檢測與 Telegram 推播)
 * @param {string} userEmail - 預約職工 Email
 * @param {string} roomId - 房間代號
 * @param {string} startTimeIso - 預約開始時間 (ISO 格式)
 * @param {string} endTimeIso - 預約結束時間 (ISO 格式)
 * @param {string} purpose - 預約用途說明
 * @returns {Object} 處理結果與預約唯一 ID
 */
function submitNewBooking(userEmail, roomId, startTimeIso, endTimeIso, purpose) {
  const config = getConfig();
  const now = new Date();
  const startTime = new Date(startTimeIso);
  const endTime = new Date(endTimeIso);

  // 1. 基礎輸入驗證
  if (startTime >= endTime) {
    return { success: false, message: "❌ 預約失敗:結束時間必須晚於開始時間。" };
  }

  if (startTime < now) {
    return { success: false, message: "❌ 預約失敗:無法預約過去的時間時段。" };
  }

  // 2. 驗證預約天數上限 (MAX_BOOKING_ADVANCE_DAYS = 90 天)
  const maxAllowedDate = new Date();
  maxAllowedDate.setDate(now.getDate() + config.MAX_BOOKING_ADVANCE_DAYS);
  if (startTime > maxAllowedDate) {
    return { success: false, message: `❌ 預約失敗:僅開放預約未來的 ${config.MAX_BOOKING_ADVANCE_DAYS} 天內時段。` };
  }

  // 3. 時間衝突檢測算法 (Interval Overlap Detection)
  const spreadsheet = SpreadsheetApp.openById(config.SPREADSHEET_ID);
  const sheet = spreadsheet.getSheetByName("Bookings");
  const data = sheet.getDataRange().getValues();
  const headers = data.shift() || [];

  const colIndex = {
    roomId: headers.indexOf("room_id"),
    startTime: headers.indexOf("start_time"),
    endTime: headers.indexOf("end_time"),
    status: headers.indexOf("status")
  };

  const reqStartMs = startTime.getTime();
  const reqEndMs = endTime.getTime();

  const hasCollision = data.some(row => {
    const status = row[colIndex.status];
    const rId = row[colIndex.roomId];

    if (rId === roomId && status === "APPROVED") {
      const existStartMs = new Date(row[colIndex.startTime]).getTime();
      const existEndMs = new Date(row[colIndex.endTime]).getTime();
      
      // 區間重疊判定公式
      return (reqStartMs < existEndMs && reqEndMs > existStartMs);
    }
    return false;
  });

  if (hasCollision) {
    return { success: false, message: "⚠️ 預約失敗:該時段已被其他職工預約,請選擇其他時段。" };
  }

  // 4. 計算節數與預計租用費用
  const durationMinutes = (reqEndMs - reqStartMs) / (1000 * 60);
  const totalSlots = durationMinutes / config.SLOT_DURATION_MINUTES;
  const estimatedFee = totalSlots * config.FEE_PER_SLOT;

  // 5. 寫入資料庫 (Google Sheets)
  const bookingId = "BK_" + new Date().getTime();
  const createdAt = new Date();

  sheet.appendRow([
    bookingId,
    roomId,
    userEmail,
    startTime,
    endTime,
    "APPROVED",
    "NOT_CHECKED_IN",
    purpose,
    createdAt,
    "" // admin_note
  ]);

  // 6. 觸發 Telegram 即時推播至管理員群組
  const formattedStart = Utilities.formatDate(startTime, "Asia/Hong_Kong", "yyyy-MM-dd HH:mm");
  const formattedEnd = Utilities.formatDate(endTime, "Asia/Hong_Kong", "HH:mm");
  
  const telegramMessage = 
    `📢 <b>【新房間預約成功通知】</b>\n` +
    `--------------------------------------\n` +
    `🆔 <b>預約編號:</b><code>${bookingId}</code>\n` +
    `🏢 <b>預約房間:</b>${roomId}\n` +
    `👤 <b>預約職工:</b>${userEmail}\n` +
    `⏰ <b>時段:</b>${formattedStart} - ${formattedEnd} (${totalSlots} 節)\n` +
    `💰 <b>預計費用:</b>HKD $${estimatedFee}\n` +
    `📝 <b>用途說明:</b>${purpose}\n` +
    `--------------------------------------\n` +
    `<i>系統已自動完成時間衝突排查並登記入庫。</i>`;

  sendTelegramNotification(telegramMessage);

  return {
    success: true,
    bookingId: bookingId,
    message: "✅ 預約成功!已發送系統推播通知。",
    estimatedFee: estimatedFee
  };
}

/**
 * 內部輔助函式:根據營業時間切分 30 分鐘時間節
 */
function generateDaySlots(dateStr, startTimeStr, endTimeStr, durationMinutes) {
  const slots = [];
  const start = new Date(`${dateStr}T${startTimeStr}:00`);
  const end = new Date(`${dateStr}T${endTimeStr}:00`);

  let current = new Date(start.getTime());

  while (current < end) {
    const slotStart = new Date(current.getTime());
    const slotEnd = new Date(current.getTime() + durationMinutes * 60 * 1000);

    if (slotEnd > end) break;

    const startLabel = Utilities.formatDate(slotStart, "Asia/Hong_Kong", "HH:mm");
    const endLabel = Utilities.formatDate(slotEnd, "Asia/Hong_Kong", "HH:mm");

    slots.push({
      timeLabel: `${startLabel} - ${endLabel}`,
      startTime: slotStart,
      endTime: slotEnd
    });

    current = slotEnd;
  }

  return slots;
}


3. 單元測試與衝突檢測模擬

你可以直接在 GAS 編輯器中執行以下測試函式,驗證時間衝突與預約寫入功能:

/**
 * Day 19 核心功能模擬測試函式
 */
function testBookingServiceFlow() {
  Logger.log("=== 開始 Day 19 核心預約 API 流程測試 ===");

  const testUser = "worker@ngo.org";
  const testRoom = "A1";
  
  // 設定測試日期為明天
  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1);
  const dateStr = Utilities.formatDate(tomorrow, "Asia/Hong_Kong", "yyyy-MM-dd");

  const startIso = `${dateStr}T10:00:00.000Z`;
  const endIso = `${dateStr}T11:00:00.000Z`;

  // 1. 測試第一筆預約寫入 (預期:成功)
  Logger.log(`1. 嘗試提交第一筆預約 (10:00 - 11:00)...`);
  const result1 = submitNewBooking(testUser, testRoom, startIso, endIso, "青少年小組聚會活動");
  Logger.log(JSON.stringify(result1));

  // 2. 測試重疊時段預約 (預期:衝突失敗)
  const overlapStartIso = `${dateStr}T10:30:00.000Z`;
  const overlapEndIso = `${dateStr}T11:30:00.000Z`;
  
  Logger.log(`2. 嘗試提交重疊時間預約 (10:30 - 11:30)...`);
  const result2 = submitNewBooking("another_worker@ngo.org", testRoom, overlapStartIso, overlapEndIso, "跨部門個案會議");
  Logger.log(JSON.stringify(result2));

  // 3. 測試時段查詢 API
  Logger.log(`3. 查詢 ${dateStr} 房間 ${testRoom} 當日可用時段...`);
  const slotsInfo = getAvailableSlots(dateStr, testRoom);
  Logger.log(`總時段數: ${slotsInfo.slots.length}`);
}


4. 小結

今天我們實作了 BookingService.gs 後端服務,解決了時間衝突檢測算法、90 天預約上限攔截、費用自動計算,以及 Telegram 即時推播通知。

明天繼續


上一篇
【 Day 18】系統需求規格說明書 (SRD) 整合與全局設定檔 (Config.gs) 設計實作
下一篇
【Day 20】取消、改期與 24 小時規章設計與實作
系列文
零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言