Hướng dẫn

Apps Script Từ A Đến Z - Bài 3: Triggers - Tự Động Hóa Theo Sự Kiện

Tuân HoangTuân Hoang
17 phút đọc
Apps Script Từ A Đến Z - Bài 3: Triggers - Tự Động Hóa Theo Sự Kiện

Apps Script Từ A Đến Z - Bài 3: Triggers - Tự Động Hóa Theo Sự Kiện

Triggers là tính năng mạnh mẽ nhất của Apps Script — cho phép script tự chạy mà không cần bạn nhấn nút. Script có thể chạy khi file mở, khi dữ liệu thay đổi, hoặc theo lịch định sẵn. Đây là nền tảng của mọi automation thực sự.

Trong bài này bạn sẽ học:
  • Simple triggers: onOpen, onEdit, onSelectionChange, onFormSubmit
  • Installable triggers: time-driven và event-driven
  • Tạo trigger bằng code (ScriptApp.newTrigger)
  • Trigger context object và các thuộc tính
  • Debug lỗi trigger thường gặp
  • 4 use case thực tế đầy đủ code

Hai Loại Triggers Chính

Simple Triggers

Simple triggers là các function với tên đặc biệt, tự động chạy khi sự kiện tương ứng xảy ra:

Function name Khi nào chạy Giới hạn
onOpen(e) Khi file được mở Không gửi email, không UrlFetch
onEdit(e) Khi người dùng sửa ô Không gửi email, không UrlFetch
onSelectionChange(e) Khi người dùng chọn ô khác Rất hạn chế
onFormSubmit(e) Khi form được submit Không gửi email, không UrlFetch
doGet(e) HTTP GET request đến Web App Chạy như người dùng anonymous
// Simple trigger: tự động tạo menu khi mở file
function onOpen(e) {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('SheetStore Tools')
    .addItem('Tạo báo cáo', 'generateReport')
    .addItem('Gửi email', 'sendDailyEmail')
    .addSeparator()
    .addSubMenu(ui.createMenu('Tiện ích')
      .addItem('Làm sạch dữ liệu', 'cleanData')
      .addItem('Format bảng', 'formatTable'))
    .addToUi();
    
  // KHÔNG làm được trong simple trigger:
  // GmailApp.sendEmail(...)  → Lỗi!
  // UrlFetchApp.fetch(...)   → Lỗi!
}

// Simple trigger: theo dõi thay đổi
function onEdit(e) {
  // e.range: Range đã được sửa
  const range = e.range;
  const sheet = range.getSheet();
  const row = range.getRow();
  const col = range.getColumn();
  
  // Thêm timestamp vào cột cuối khi sửa
  if (sheet.getName() === 'Orders' && col !== sheet.getLastColumn()) {
    const lastCol = sheet.getLastColumn();
    sheet.getRange(row, lastCol).setValue(new Date());
  }
  
  // Highlight hàng vừa sửa
  sheet.getRange(row, 1, 1, sheet.getLastColumn())
    .setBackground('#fff9c4');
}

Installable Triggers

Installable triggers mạnh hơn simple triggers — có thể gửi email, gọi API, và chạy dưới quyền người cài đặt (không phải người dùng hiện tại).

Time-Driven Triggers

// Tạo các loại time-driven triggers bằng code
function createTimeTriggers() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  // Chạy mỗi phút (minutely)
  ScriptApp.newTrigger('myFunction')
    .timeBased()
    .everyMinutes(5)  // 1, 5, 10, 15, 30
    .create();

  // Chạy mỗi giờ
  ScriptApp.newTrigger('sendHourlyReport')
    .timeBased()
    .everyHours(1)    // 1, 2, 4, 6, 8, 12
    .create();

  // Chạy mỗi ngày lúc 8 giờ sáng
  ScriptApp.newTrigger('sendDailyReport')
    .timeBased()
    .everyDays(1)
    .atHour(8)        // 0-23
    .inTimezone('Asia/Ho_Chi_Minh')
    .create();

  // Chạy mỗi tuần thứ 2 lúc 9h
  ScriptApp.newTrigger('weeklyBackup')
    .timeBased()
    .onWeekDay(ScriptApp.WeekDay.MONDAY)
    .atHour(9)
    .inTimezone('Asia/Ho_Chi_Minh')
    .create();

  // Chạy mỗi tháng ngày 1
  ScriptApp.newTrigger('monthlyReport')
    .timeBased()
    .onMonthDay(1)
    .atHour(7)
    .inTimezone('Asia/Ho_Chi_Minh')
    .create();

  // Chạy 1 lần vào ngày cụ thể
  ScriptApp.newTrigger('oneTimeTask')
    .timeBased()
    .at(new Date('2027-03-15T09:00:00'))
    .create();

  Logger.log('Đã tạo tất cả time triggers');
}

Event-Driven Installable Triggers

function createEventTriggers() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  // onEdit installable (mạnh hơn simple trigger, có thể gửi email)
  ScriptApp.newTrigger('onEditInstallable')
    .forSpreadsheet(ss)
    .onEdit()
    .create();

  // onChange: khi cấu trúc sheet thay đổi (thêm/xóa sheet, rename...)
  ScriptApp.newTrigger('onChangeHandler')
    .forSpreadsheet(ss)
    .onChange()
    .create();

  // onFormSubmit (từ Google Form liên kết)
  ScriptApp.newTrigger('processFormResponse')
    .forSpreadsheet(ss)
    .onFormSubmit()
    .create();

  Logger.log('Event triggers created');
}

Quản Lý Triggers

// Liệt kê tất cả triggers
function listTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  
  triggers.forEach(trigger => {
    Logger.log('Function: %s | Type: %s | Source: %s',
      trigger.getHandlerFunction(),
      trigger.getEventType(),
      trigger.getTriggerSource()
    );
  });
  
  Logger.log('Tổng số triggers: ' + triggers.length);
}

// Xóa tất cả triggers của một function
function deleteTriggersByFunction(functionName) {
  const triggers = ScriptApp.getProjectTriggers();
  
  triggers.forEach(trigger => {
    if (trigger.getHandlerFunction() === functionName) {
      ScriptApp.deleteTrigger(trigger);
      Logger.log('Đã xóa trigger: ' + trigger.getUniqueId());
    }
  });
}

// Xóa tất cả triggers (reset)
function deleteAllTriggers() {
  const triggers = ScriptApp.getProjectTriggers();
  triggers.forEach(trigger => ScriptApp.deleteTrigger(trigger));
  Logger.log('Đã xóa ' + triggers.length + ' triggers');
}

// Tránh tạo duplicate triggers
function safeCreateTrigger(functionName) {
  const triggers = ScriptApp.getProjectTriggers();
  
  // Kiểm tra đã có trigger chưa
  const exists = triggers.some(t => t.getHandlerFunction() === functionName);
  
  if (!exists) {
    ScriptApp.newTrigger(functionName)
      .timeBased()
      .everyDays(1)
      .atHour(8)
      .create();
    Logger.log('Tạo trigger mới cho: ' + functionName);
  } else {
    Logger.log('Trigger đã tồn tại cho: ' + functionName);
  }
}

Trigger Context Object (Event Object)

// onEdit event object
function onEditInstallable(e) {
  if (!e) {
    Logger.log('Chạy thủ công, không có event object');
    return;
  }

  // e.source: Spreadsheet object
  const ss = e.source;
  Logger.log('File: ' + ss.getName());

  // e.range: Range vừa được edit
  const range = e.range;
  Logger.log('Cell: ' + range.getA1Notation());
  Logger.log('Sheet: ' + range.getSheet().getName());
  Logger.log('Row: ' + range.getRow());
  Logger.log('Column: ' + range.getColumn());

  // e.value: Giá trị mới (chỉ có với single cell edit)
  Logger.log('New value: ' + e.value);

  // e.oldValue: Giá trị cũ (nếu có)
  Logger.log('Old value: ' + e.oldValue);

  // e.user: Người dùng đã edit
  if (e.user) {
    Logger.log('User: ' + e.user.getEmail());
  }
}

// onFormSubmit event object
function processFormResponse(e) {
  const response = e.response;
  const namedValues = e.namedValues; // { 'Question': ['Answer'], ... }
  
  Logger.log('Form submitted at: ' + response.getTimestamp());
  Logger.log('Email: ' + namedValues['Email'][0]);
  Logger.log('Name: ' + namedValues['Họ tên'][0]);
  
  // Xử lý response...
  const email = namedValues['Email'][0];
  const name = namedValues['Họ tên'][0];
  
  // Gửi email xác nhận (được phép trong installable trigger)
  GmailApp.sendEmail(email, 'Xác nhận đăng ký', 
    'Xin chào ' + name + ', đăng ký của bạn đã được ghi nhận!');
}

// onChange event object
function onChangeHandler(e) {
  Logger.log('Change type: ' + e.changeType);
  // changeType: INSERT_ROW, INSERT_COLUMN, REMOVE_ROW, REMOVE_COLUMN, 
  //             INSERT_GRID, REMOVE_GRID, FORMAT, OTHER
  
  if (e.changeType === 'INSERT_ROW') {
    Logger.log('Có hàng mới được thêm vào');
  }
}

Debug Triggers: Các Lỗi Phổ Biến

Lỗi #1: Trigger không chạy
Nguyên nhân thường gặp: (1) Quota hết, (2) Script lỗi nhưng không có notification, (3) Sai tên function.
// Debug pattern chuẩn cho trigger
function myTriggerFunction(e) {
  try {
    // Logic chính
    doMainWork(e);
  } catch (error) {
    // Log lỗi vào sheet để dễ debug
    logError(error, 'myTriggerFunction', e);
    
    // Optionally gửi email báo lỗi
    // GmailApp.sendEmail('admin@company.com', 'Trigger Error', error.toString());
  }
}

function logError(error, functionName, eventObj) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let logSheet = ss.getSheetByName('ErrorLog');
  
  if (!logSheet) {
    logSheet = ss.insertSheet('ErrorLog');
    logSheet.appendRow(['Timestamp', 'Function', 'Error', 'Event']);
  }
  
  logSheet.appendRow([
    new Date(),
    functionName,
    error.toString(),
    JSON.stringify(eventObj || {})
  ]);
}

// Tránh infinite loop trong onEdit
function onEditSafe(e) {
  // Lỗi phổ biến: onEdit chỉnh sửa sheet → kích hoạt onEdit lại → loop!
  const range = e.range;
  const sheet = range.getSheet();
  
  // Guard: chỉ xử lý sheet 'Data', không phải 'ErrorLog' hay 'Timestamp'
  if (sheet.getName() !== 'Data') return;
  
  // Guard: không xử lý cột timestamp (cột cuối) để tránh loop
  const lastCol = sheet.getLastColumn();
  if (range.getColumn() === lastCol) return;
  
  // Guard: chỉ xử lý single cell edit
  if (range.getNumRows() > 1 || range.getNumColumns() > 1) return;
  
  // An toàn để ghi timestamp
  sheet.getRange(range.getRow(), lastCol).setValue(new Date());
}

Xem Trigger Execution Logs

// Trigger logs xem tại: Executions (menu bên trái trong IDE)
// Hoặc View → Stackdriver Logging

// Tất cả console.log trong trigger đều vào Stackdriver
function myTriggerWithLogs(e) {
  console.log('Trigger started:', new Date().toISOString());
  console.log('Event:', JSON.stringify(e));
  
  try {
    // Work here
    console.log('Work completed successfully');
  } catch (err) {
    console.error('Error in trigger:', err.message, err.stack);
  }
}

Use Case 1: Auto Backup Hàng Ngày

// Setup: Chạy setupDailyBackup() một lần để tạo trigger
function setupDailyBackup() {
  deleteTriggersByFunction('dailyBackup');
  ScriptApp.newTrigger('dailyBackup')
    .timeBased()
    .everyDays(1)
    .atHour(2) // 2 giờ sáng
    .inTimezone('Asia/Ho_Chi_Minh')
    .create();
  Logger.log('Daily backup trigger đã được tạo');
}

function dailyBackup() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const today = Utilities.formatDate(new Date(), 'Asia/Ho_Chi_Minh', 'yyyy-MM-dd');
  
  // Tạo copy file
  const sourceFile = DriveApp.getFileById(ss.getId());
  const backupFolder = getOrCreateFolder('SheetStore Backups');
  
  const backup = sourceFile.makeCopy('Backup_' + today + '_' + ss.getName(), backupFolder);
  
  // Xóa backup cũ hơn 30 ngày
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
  
  const files = backupFolder.getFiles();
  let deletedCount = 0;
  while (files.hasNext()) {
    const file = files.next();
    if (file.getDateCreated() < thirtyDaysAgo && file.getName() !== backup.getName()) {
      file.setTrashed(true);
      deletedCount++;
    }
  }
  
  console.log('Backup created: ' + backup.getName() + ', deleted ' + deletedCount + ' old backups');
}

function getOrCreateFolder(folderName) {
  const folders = DriveApp.getFoldersByName(folderName);
  if (folders.hasNext()) return folders.next();
  return DriveApp.createFolder(folderName);
}

Use Case 2: Gửi Email Khi Form Submit

// Setup: Chạy setupFormTrigger() một lần
function setupFormTrigger() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  deleteTriggersByFunction('onFormSubmitHandler');
  ScriptApp.newTrigger('onFormSubmitHandler')
    .forSpreadsheet(ss)
    .onFormSubmit()
    .create();
  Logger.log('Form trigger created');
}

function onFormSubmitHandler(e) {
  const namedValues = e.namedValues;
  
  // Lấy dữ liệu từ form
  const customerName = namedValues['Họ và tên'] ? namedValues['Họ và tên'][0] : '';
  const customerEmail = namedValues['Email'] ? namedValues['Email'][0] : '';
  const orderType = namedValues['Loại đơn hàng'] ? namedValues['Loại đơn hàng'][0] : '';
  const notes = namedValues['Ghi chú'] ? namedValues['Ghi chú'][0] : 'Không có';
  
  if (!customerEmail) {
    console.error('Không có email khách hàng');
    return;
  }
  
  // Email cho khách hàng
  const customerEmailBody = 
    'Xin chào ' + customerName + ',

' +
    'Chúng tôi đã nhận được yêu cầu của bạn:
' +
    '- Loại: ' + orderType + '
' +
    '- Ghi chú: ' + notes + '

' +
    'Chúng tôi sẽ liên hệ trong 24 giờ làm việc.

' +
    'Trân trọng,
Đội ngũ SheetStore';

  GmailApp.sendEmail(customerEmail, 
    '[SheetStore] Xác nhận yêu cầu của bạn', 
    customerEmailBody);
  
  // Email cho admin
  const adminEmail = 'admin@sheetstore.vn';
  GmailApp.sendEmail(adminEmail,
    '[NEW] Yêu cầu mới từ ' + customerName,
    'Khách hàng: ' + customerName + '
' +
    'Email: ' + customerEmail + '
' +
    'Loại: ' + orderType + '
' +
    'Ghi chú: ' + notes + '
' +
    'Thời gian: ' + new Date()
  );
  
  console.log('Emails sent for: ' + customerEmail);
}

Use Case 3: Alert Khi Giá Trị Vượt Ngưỡng

function onEditAlertThreshold(e) {
  if (!e) return;
  
  const range = e.range;
  const sheet = range.getSheet();
  
  // Chỉ theo dõi sheet 'KPIs', cột C (Target), cột D (Actual)
  if (sheet.getName() !== 'KPIs') return;
  if (range.getColumn() !== 4) return; // Chỉ khi cập nhật cột D (Actual)
  
  const row = range.getRow();
  if (row < 2) return; // Bỏ qua header
  
  const metricName = sheet.getRange(row, 1).getValue();
  const target = parseFloat(sheet.getRange(row, 3).getValue() || 0);
  const actual = parseFloat(e.value || 0);
  
  if (target === 0) return;
  
  const achievement = (actual / target) * 100;
  
  // Alert nếu đạt dưới 80% target
  if (achievement < 80) {
    const adminEmail = Session.getActiveUser().getEmail();
    
    GmailApp.sendEmail(adminEmail,
      '[CẢNH BÁO] KPI thấp: ' + metricName,
      'Cảnh báo KPI:
' +
      'Chỉ số: ' + metricName + '
' +
      'Mục tiêu: ' + target.toLocaleString() + '
' +
      'Thực tế: ' + actual.toLocaleString() + '
' +
      'Tỷ lệ đạt: ' + achievement.toFixed(1) + '%

' +
      'Vui lòng kiểm tra và có hành động kịp thời.'
    );
    
    // Highlight ô màu đỏ
    range.setBackground('#ffcdd2');
  } else if (achievement >= 100) {
    // Đạt target: màu xanh
    range.setBackground('#c8e6c9');
  } else {
    // Bình thường: màu vàng
    range.setBackground('#fff9c4');
  }
}

Use Case 4: Tự Động Format Khi Thêm Row Mới

function onEditAutoFormat(e) {
  if (!e) return;
  
  const range = e.range;
  const sheet = range.getSheet();
  
  if (sheet.getName() !== 'Orders') return;
  
  // Chỉ xử lý khi thêm dữ liệu vào cột A (SKU/ID đơn hàng)
  if (range.getColumn() !== 1 || range.getRow() < 2) return;
  if (!e.value || e.value === '') return;
  
  const row = range.getRow();
  const numCols = 8; // Số cột trong bảng orders
  const fullRow = sheet.getRange(row, 1, 1, numCols);
  
  // Alternating row colors
  const bgColor = (row % 2 === 0) ? '#f8f9fa' : '#ffffff';
  fullRow.setBackground(bgColor);
  
  // Format cột ngày (cột 2)
  sheet.getRange(row, 2).setNumberFormat('dd/MM/yyyy HH:mm');
  
  // Auto-fill ngày hiện tại nếu cột 2 trống
  const dateCell = sheet.getRange(row, 2);
  if (!dateCell.getValue()) {
    dateCell.setValue(new Date());
  }
  
  // Format cột tiền (cột 5, 6, 7)
  sheet.getRange(row, 5, 1, 3).setNumberFormat('#,##0 "đ"');
  
  // Auto-generate Order ID nếu cần
  const orderIdCell = sheet.getRange(row, 1);
  if (orderIdCell.getValue() === 'AUTO') {
    const orderId = 'ORD-' + Utilities.formatDate(new Date(), 'Asia/Ho_Chi_Minh', 'yyyyMMdd') 
      + '-' + String(row - 1).padStart(4, '0');
    orderIdCell.setValue(orderId);
  }
  
  console.log('Auto-formatted row ' + row);
}

Tóm Tắt: Chọn Trigger Phù Hợp

Nhu cầu Dùng trigger nào
Thêm menu khi mở file Simple onOpen
Gửi email khi form submit Installable onFormSubmit
Backup hàng ngày lúc 2h sáng Time-driven daily
Alert khi ô thay đổi + gửi email Installable onEdit
Format nhanh không cần email Simple onEdit
Sync data mỗi 5 phút Time-driven minutely
Bài tiếp theo: Bài 4 — GmailApp: Gửi Email, Đọc Inbox và Tạo Draft
Sau Triggers, chúng ta sẽ học GmailApp để xử lý email tự động: gửi email HTML, đọc inbox, tạo draft và quản lý labels. Kết hợp với Triggers từ bài này để tạo email automation hoàn chỉnh.

Chia sẻ bài viết:

Tuân Hoang

Tuân Hoang

Đội ngũ SheetStore

Bạn thấy bài viết hữu ích?

Đăng ký nhận thông báo khi có bài viết mới.

Nhận thông báo khi có bài viết mới. Không spam, hứa luôn! 😊

Bình luận (0)

Vui lòng đăng nhập để tham gia thảo luận