TL;DR
Custom Functions trong Google Sheets chạy bên trong một sandbox bị khóa chặt. Sandbox đó chặn tất cả những gì yêu cầu OAuth — và SpreadsheetApp.openById() chắc chắn cần đến nó. Bạn không thể gọi hàm này từ công thức =MY_FUNCTION(), đơn giản vậy thôi. Hãy chuyển logic sang một hàm Apps Script thông thường, một installable trigger, hoặc một hàm gắn với menu thay thế.
Lỗi chính xác
Exception: You do not have permission to call SpreadsheetApp.openById. (line X)
Lỗi hiển thị trực tiếp trong ô đã gọi hàm của bạn — thay vì giá trị, bạn nhận được thông báo này. Di chuột qua ô đó và bạn sẽ thấy nó dưới dạng tooltip hình tam giác đỏ.
Tại sao điều này xảy ra
Khi bạn gọi một hàm từ ô như =MYFUNCTION(A1), Google chạy nó trong cái mà họ gọi là "custom function sandbox". Hãy coi đó như một vùng chỉ đọc, không có xác thực. Google xây dựng như vậy là có chủ ý — các công thức tính toán lại liên tục và âm thầm, nên nếu cho chúng quyền truy cập OAuth đầy đủ thì một bảng tính có thể lặng lẽ rò rỉ dữ liệu của bạn mỗi khi một ô thay đổi.
Sandbox chặn nhiều thứ mà bạn có thể nghĩ sẽ hoạt động được:
- Mở các bảng tính khác (
SpreadsheetApp.openById(),openByUrl()) - Các dịch vụ bên ngoài cần OAuth (
GmailApp,DriveApp, các lệnh gọiUrlFetchAppriêng tư) - Thay đổi cấu trúc bảng tính (chèn hàng, đặt định dạng)
- Bất cứ thứ gì liên quan đến UI (
SpreadsheetApp.getUi())
Một cách hình dung dễ hiểu: nếu nó sẽ kích hoạt hộp thoại xác nhận OAuth, thì nó không hoạt động trong custom function. Tài liệu Apps Script có danh sách đầy đủ, nhưng quy tắc đó giúp bạn xử lý được 90% trường hợp.
Cách sửa 1 — Dùng getActiveSpreadsheet() thay thế (chỉ cùng một bảng tính)
Cần dữ liệu từ chính bảng tính chứa công thức? Thay openById() bằng getActiveSpreadsheet(). Hàm đó được phép dùng trong custom functions.
// ❌ Thất bại trong custom function
function getSheetValue(sheetName, row, col) {
const ss = SpreadsheetApp.openById('YOUR_SPREADSHEET_ID');
const sheet = ss.getSheetByName(sheetName);
return sheet.getRange(row, col).getValue();
}
// ✅ Hoạt động — đọc từ bảng tính hiện tại
function getSheetValue(sheetName, row, col) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(sheetName);
if (!sheet) return 'Sheet not found';
return sheet.getRange(row, col).getValue();
}
Một lưu ý: bạn chỉ có thể đọc dữ liệu theo cách này. Việc ghi lại vào sheet từ bên trong custom function cũng bị chặn.
Cách sửa 2 — Chuyển logic sang hàm menu hoặc nút bấm
Cần mở một bảng tính hoàn toàn khác? Logic phải rời khỏi sandbox của custom function. Gắn nó vào một mục menu là cách tiếp cận gọn gàng nhất — chỉ vài dòng boilerplate là xong.
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('My Tools')
.addItem('Pull Data from Other Sheet', 'pullFromOtherSheet')
.addToUi();
}
function pullFromOtherSheet() {
// Hàm thông thường — quyền đầy đủ, không có sandbox
const ss = SpreadsheetApp.openById('YOUR_SPREADSHEET_ID');
const data = ss.getSheetByName('Data').getRange('A1:B10').getValues();
SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('Results')
.getRange(1, 1, data.length, data[0].length)
.setValues(data);
}
Quy trình: người dùng nhấp vào mục menu → hàm chạy với đầy đủ quyền → dữ liệu xuất hiện trên sheet → các công thức của bạn tham chiếu bình thường. Không có sandbox nào tham gia.
Cách sửa 3 — Dùng installable time-driven trigger
Muốn chạy theo lịch thay vì theo thao tác của người dùng? Hãy thiết lập một installable trigger. Khác với simple triggers (onOpen, onEdit), installable triggers chạy với toàn bộ quyền của người đã tạo chúng.
// Chạy một lần để đăng ký trigger
function createTrigger() {
ScriptApp.newTrigger('syncFromOtherSheet')
.timeBased()
.everyHours(1)
.create();
}
function syncFromOtherSheet() {
const source = SpreadsheetApp.openById('SOURCE_SPREADSHEET_ID');
const target = SpreadsheetApp.getActiveSpreadsheet();
const values = source.getSheetByName('Export')
.getDataRange()
.getValues();
target.getSheetByName('Import')
.clearContents()
.getRange(1, 1, values.length, values[0].length)
.setValues(values);
}
Đăng ký qua Extensions → Apps Script → Triggers, hoặc chạy createTrigger() một lần từ trình soạn thảo. Sau đó, nó tự động kích hoạt mỗi giờ mà không cần người dùng thao tác.
Cách sửa 4 — Tái cấu trúc để truyền dữ liệu qua tham số hàm
Đôi khi cách sửa thực sự là suy nghĩ lại về thiết kế. Thay vì để custom function tự kéo dữ liệu từ bảng tính khác, hãy nhập trước dữ liệu bạn cần (qua trigger hoặc import một lần) và lưu cache cục bộ. Sau đó công thức của bạn chỉ cần đọc các ô lân cận.
// Thay vì làm thế này:
// =GET_REMOTE_PRICE("AAPL")
// vốn cố gọi openById() bên trong...
// Hãy làm thế này:
// 1. Một trigger kéo giá vào cột B của một sheet ẩn mỗi 15 phút
// 2. Công thức của bạn dùng VLOOKUP trên dữ liệu cache đó
// =VLOOKUP("AAPL", PriceCache!A:B, 2, FALSE)
Pattern này vốn dĩ cũng bền vững hơn. Tách biệt việc lấy dữ liệu khỏi việc tính toán công thức có nghĩa là sheet của bạn ngừng gọi đến các dịch vụ bên ngoài mỗi lần tính toán lại — trên một sheet lớn với hàng trăm công thức, sự khác biệt đó rất rõ ràng.
Xác minh — kiểm tra xem cách sửa có hoạt động không
- Tải lại bảng tính (
Ctrl+R/Cmd+R) sau khi áp dụng bất kỳ cách sửa nào. - Đã chuyển logic sang menu? Chạy hàm thủ công từ trình soạn thảo Apps Script trước. Xác nhận nó hoàn thành sạch sẽ trước khi kiểm tra qua mục menu.
- Đang dùng trigger? Vào Extensions → Apps Script → Triggers và kiểm tra tab Executions sau khi nó kích hoạt. Dấu kiểm màu xanh nghĩa là đã thành công.
- Đã chuyển sang
getActiveSpreadsheet()? Ô đó bây giờ sẽ hiển thị giá trị thay vì lỗi. Nếu vẫn thất bại, mở execution log (View → Executions) — có thể có một lỗi khác bên dưới.
Tham chiếu nhanh — những gì được phép trong custom functions
Được phépKhông được phép
`SpreadsheetApp.getActiveSpreadsheet()``SpreadsheetApp.openById()`
Đọc giá trị ôGhi/chỉnh sửa sheet
Toán học, xử lý chuỗi`GmailApp`, `DriveApp`
Gọi `UrlFetchApp` công khai (không cần xác thực)Gọi API có xác thực
`CacheService`, `Utilities``SpreadsheetApp.getUi()`
Mẹo
Xây dựng các công cụ bảng tính kết hợp custom functions với đồng bộ nền sẽ nhanh chóng trở nên phức tạp. Giữ một mô hình tư duy rõ ràng về cái gì chạy ở đâu — sandbox so với quyền đầy đủ — giúp tiết kiệm rất nhiều thời gian đau đầu. Khi gỡ lỗi cấu trúc dữ liệu trong những tích hợp như này, đôi khi tôi dùng ToolCraft để kiểm tra payload JSON cục bộ. Không có gì bị tải lên, điều này quan trọng khi dữ liệu bảng tính có tính nhạy cảm.
Quy tắc giải quyết tất cả: nếu người dùng cần nhấp "Allow" trong hộp thoại xác nhận OAuth, thì nó không thể nằm trong custom function. Hãy thiết kế xoay quanh ràng buộc đó. Đừng cố chống lại nó.

