生成AIが問い合わせ返信の下書きを作っても、承認者が休暇や会議で不在なら処理は止まります。しかし、止まるたびに「誰でも承認可」に変えると、金額、契約、苦情、個人情報を含む返信まで権限が広がります。
この記事ではGoogle SheetsとGoogle Apps Script(GAS)を使い、通常承認者が不在のときだけ、カテゴリと有効期限を限定して代理承認できる最小構成を作ります。AI APIやメール自動送信は扱いません。完成状態は「代理人ができることを行単位で検証し、条件外なら必ず停止する」です。
この記事で作るもの
-
ApprovalPolicies: 通常承認者、代理承認者、対象カテゴリ、有効期限を管理 -
ReplyQueue: AI下書き、リスク区分、承認状態を管理 -
ApprovalAudit: 誰が、どの権限で、何を承認したかを追記 - 条件外の代理承認を
BLOCKEDにするGAS - 再実行しても二重承認・二重ログを作らない仕組み
シートの設計
ApprovalPolicies
1行目に次の列を置きます。
policy_id,primary_reviewer,delegate_reviewer,allowed_categories,max_risk,valid_from,valid_until,enabled,updated_by,updated_at
例です。
POL-001,owner@example.com,sub@example.com,general|schedule,LOW,2026-07-23T09:00:00+09:00,2026-07-25T18:00:00+09:00,true,owner@example.com,2026-07-22T17:00:00+09:00
allowed_categories は代理人が扱えるカテゴリだけを | 区切りで指定します。max_risk は LOW、MEDIUM、HIGH の上限です。代理承認では HIGH を許可しない運用を推奨します。
ReplyQueue
ticket_id,category,risk,draft,evidence_url,status,requested_reviewer,acted_by,approval_mode,approved_at,stop_reason
AIが作るのは draft までです。evidence_url には参照したFAQや社内正本の識別子を置き、問い合わせ原文や個人情報を不要に複製しません。
ApprovalAudit
event_id,ticket_id,action,actor,approval_mode,policy_id,reason,created_at
監査ログは更新せず追記だけにします。現在状態は ReplyQueue、履歴は ApprovalAudit と役割を分けます。
GASを実装する
スプレッドシートに紐づくApps Scriptへ次を追加します。
const RISK_SCORE = { LOW: 1, MEDIUM: 2, HIGH: 3 };
function approveReply(ticketId, actorEmail) {
const lock = LockService.getDocumentLock();
lock.waitLock(10000);
try {
const ss = SpreadsheetApp.getActive();
const queue = table_(ss.getSheetByName('ReplyQueue'));
const policies = table_(ss.getSheetByName('ApprovalPolicies'));
const row = queue.rows.find(r => r.ticket_id === ticketId);
if (!row) throw new Error(`ticket not found: ${ticketId}`);
if (row.status === 'APPROVED') return { ok: true, duplicate: true };
if (!['DRAFTED', 'REVIEWING', 'BLOCKED'].includes(row.status)) {
return block_(queue, row, actorEmail, 'INVALID_STATUS');
}
const now = new Date();
let mode = 'PRIMARY';
let policyId = '';
if (actorEmail !== row.requested_reviewer) {
const policy = policies.rows.find(p =>
p.primary_reviewer === row.requested_reviewer &&
p.delegate_reviewer === actorEmail &&
String(p.enabled).toLowerCase() === 'true' &&
new Date(p.valid_from) <= now && now <= new Date(p.valid_until)
);
if (!policy) return block_(queue, row, actorEmail, 'NO_ACTIVE_DELEGATION');
const categories = String(policy.allowed_categories).split('|');
if (!categories.includes(row.category)) {
return block_(queue, row, actorEmail, 'CATEGORY_NOT_ALLOWED');
}
if (RISK_SCORE[row.risk] > RISK_SCORE[policy.max_risk]) {
return block_(queue, row, actorEmail, 'RISK_TOO_HIGH');
}
mode = 'DELEGATED';
policyId = policy.policy_id;
}
updateRow_(queue, row._row, {
status: 'APPROVED',
acted_by: actorEmail,
approval_mode: mode,
approved_at: now.toISOString(),
stop_reason: ''
});
appendAudit_(ss, ticketId, 'APPROVED', actorEmail, mode, policyId, '');
return { ok: true, mode };
} finally {
lock.releaseLock();
}
}
条件外の操作は例外で終わらせず、停止理由を残します。
function block_(queue, row, actor, reason) {
updateRow_(queue, row._row, { status: 'BLOCKED', stop_reason: reason });
appendAudit_(SpreadsheetApp.getActive(), row.ticket_id,
'BLOCKED', actor, 'NONE', '', reason);
return { ok: false, reason };
}
function appendAudit_(ss, ticketId, action, actor, mode, policyId, reason) {
const sh = ss.getSheetByName('ApprovalAudit');
const eventId = `${ticketId}:${action}:${actor}:${policyId || 'primary'}`;
const ids = sh.getLastRow() < 2 ? []
: sh.getRange(2, 1, sh.getLastRow() - 1, 1).getValues().flat();
if (ids.includes(eventId)) return;
sh.appendRow([eventId, ticketId, action, actor, mode, policyId, reason,
new Date().toISOString()]);
}
function table_(sheet) {
const values = sheet.getDataRange().getValues();
const headers = values.shift().map(String);
return {
sheet,
headers,
rows: values.map((line, index) => Object.fromEntries(
headers.map((h, i) => [h, line[i]]).concat([['_row', index + 2]])
))
};
}
function updateRow_(table, rowNumber, changes) {
Object.entries(changes).forEach(([key, value]) => {
const column = table.headers.indexOf(key) + 1;
if (!column) throw new Error(`column not found: ${key}`);
table.sheet.getRange(rowNumber, column).setValue(value);
});
}
Webアプリから実行する場合の注意
ブラウザから承認ボタンを作る場合、actorEmail をリクエスト本文から信用してはいけません。実行環境で取得できるログイン中ユーザー、または組織の認証基盤で検証した識別子を使います。個人Googleアカウントや実行設定によって Session.getActiveUser().getEmail() が空になることもあるため、取得不能なら承認させず停止します。
function doPost(e) {
const actor = Session.getActiveUser().getEmail();
if (!actor) {
return json_({ ok: false, reason: 'ACTOR_NOT_VERIFIED' });
}
const body = JSON.parse(e.postData.contents || '{}');
return json_(approveReply(String(body.ticket_id || ''), actor));
}
function json_(value) {
return ContentService.createTextOutput(JSON.stringify(value))
.setMimeType(ContentService.MimeType.JSON);
}
Apps Script Webアプリの公開範囲を「全員」にしない、承認URLをメールへ直接埋め込まない、GETで状態変更しない、という3点も守ります。
テストケース
最低限、次を確認します。
- 通常承認者が
LOWを承認できる - 有効期間内の代理人が許可カテゴリの
LOWを承認できる - 代理人が許可外カテゴリを操作すると
CATEGORY_NOT_ALLOWEDで停止する - 代理人が
HIGHを操作するとRISK_TOO_HIGHで停止する - 委任期限後は
NO_ACTIVE_DELEGATIONで停止する - 同じ承認を再実行しても監査ログが増えない
- 実行者を検証できない場合は
ACTOR_NOT_VERIFIEDで停止する
テスト用シートを複製し、送信処理を接続していない状態で実行してください。失敗系のテストを先に通すと、権限の抜けを見つけやすくなります。
運用チェックリスト
- 委任は開始・終了日時を必須にしたか
- 代理人が扱えるカテゴリとリスク上限を絞ったか
- 金額、契約、苦情、個人情報、法務判断は通常承認者へ戻すか
- 実行者IDをクライアント入力から信用していないか
-
APPROVEDと実際のSENTを分けたか - 代理承認の件数と停止理由を週次で確認するか
- 退職・異動時に委任設定を無効化する担当者が決まっているか
この構成が自動化しないこと
この仕組みは、返信内容の正しさ、契約判断、法的判断、送信先や添付の最終確認を自動化しません。代理承認を許可しても、外部送信は別操作にし、重要案件は通常承認者または専門担当へ戻します。
MiraigentではAI導入前の業務整理で、通常系だけでなく「担当者が不在なら誰が、どの範囲まで判断できるか」も確認します。まずは1カテゴリ、短い有効期限、送信なしのテストから始めると安全です。
