在實作完核心預約與 AI / Telegram 整合後,今天進入 模組 C:取消、改期與規章執行層——CancellationService.gs。
本模組嚴格遵循訂定的 SRD 業務規章:
CANCELLED,不收取任何費用 / 但須在60日內重新預訂。LATE_CANCELLED,視為已使用時段並強制收取全額費用。[職工提交取消請求]
│
▼
計算 ΔT = (預約開始時間 - 當前時間)
│
┌────┴─────────────────────────┐
▼ ▼
ΔT ≥ 24 小時 ΔT < 24 小時
│ │
[狀態: CANCELLED] [狀態: LATE_CANCELLED]
[費用: HKD $0] [費用: 照常全額計費]
│ │
└──────────────┬───────────────┘
▼
更新 Google Sheet 狀態
│
▼
發送 Telegram 警示通知
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}`
};
}
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));
}
執行測試後,你的 Telegram 群組將會即時收到如下格式的推播:
🚨 【房間預約異動 - 取消通知】
🆔 預約編號:
BK_1700000000000🏢 預約房間: TALK_ROOM
👤 申請職工: worker@ngo.org
⏰ 原預約時段: 2026-10-05 14:00
📌 最終取消狀態:LATE_CANCELLED
💰 費用結算: HKD $100 (⚠️ 距離預約不足 24 小時,依規章實施晚取消 (LATE_CANCELLED),費用照常收取。)
💬 取消原因: 職工臨時生病無法出席
系統已依據 24HR 規章更新資料庫。