GASで電子稟議システムを作り直した話
TL;DR
- 既存の電子稟議システムは起案にスプレッドシート操作が必要で、担当者によってミスや抜けが出ていた
- 起案フォームをHTML Webアプリ化し、ブラウザから入力するだけで稟議が出せるようにした
- 決裁者へのメールにリンクが届き、クリックするだけでブラウザ上で承認・否認できる
- スプレッドシートはDBとして裏で動き、ユーザーは一切触らなくていい設計にした
- 設定シート1枚で組織固有値を管理し、他部署・他大学にそのまま展開できる
背景:「動いているけど使いにくい」システム
既存の電子稟議システムがあった。GASで動いていて、一応機能はする。ただし起案者からの声が絶えなかった。
起案がとにかく手間だった。
既存の仕組みはスプレッドシートの特定のシートを直接操作する設計になっていた。起案するには所定のシートを開き、指定の列に値を入力し、決裁者の欄に順番通りにメールアドレスを書き込む。入力規則や説明が書いてあっても、慣れていない人には難しい。誤った列に入力したり、決裁者の順序を間違えたりするミスが発生していた。
決裁者側も同じだった。
承認依頼のメールが届いても、承認するにはスプレッドシートを開いて該当行を探し、特定のセルに「承認」と手入力する手順が必要だった。スプレッドシートの場所がわからない、どのセルに書けばいいかわからない、という問い合わせが事務担当に来ていた。
スプレッドシートを「フォーム」として使うことの限界だった。
解決の方針:UIをスプレッドシートから切り離す
問題の本質は「スプレッドシートを操作インターフェースとして使っていること」にある。
GASにはWebアプリとしてHTMLページを配信する機能がある。URLにアクセスするとブラウザでフォームが開く。これを使えば、スプレッドシートをユーザーに触らせずに済む。
方針を3つ決めた。
① 起案はHTMLフォームで
ブラウザでURLを開くと起案フォームが表示される。件名・内容・決裁者を入力して送信するだけ。スプレッドシートを開く必要はない。
② 承認もブラウザで完結させる
決裁者のメールにリンクが届く。リンクをクリックするとHTMLの承認フォームが開く。承認・否認・専決・決裁不要を選んで送信する。スプレッドシートを触らせない。
③ スプレッドシートはDBとして使う
ユーザーには見せない。裏でデータを記録・管理するだけの役割に割り切る。
アーキテクチャ
ファイル構成
GAS/
├── Code.gs ← doGet ルーティング
├── config.gs ← 設定シート読み取り
├── storage.gs ← スプレッドシートDB操作
├── workflow.gs ← 承認フロー制御
├── complete.gs ← 完了処理・Drive格納
├── mail.gs ← メール送信
├── token.gs ← 承認トークン管理
├── meibo.gs ← 名簿取得
├── StartForm.html ← 起案フォーム
├── ApproveForm.html ← 承認フォーム
├── StatusPage.html ← 進捗確認
├── AdminDashboard.html ← 管理一覧
├── AdminDetail.html ← 管理詳細
└── _Styles.html ← 共通CSS
スプレッドシートのシート構成
| シート名 | 役割 |
|---|---|
| 起案一覧 | 1稟議 = 1行(ステータス管理も兼ねる) |
| 決裁者一覧 | 1決裁者 = 1行(トークンもここに格納) |
| 設定シート | 全組織固有値 |
| 名簿 | 決裁者候補(氏名・メール・所属) |
| 格納先 | 完了後の格納先Driveフォルダ一覧 |
| 決裁者テンプレート | よく使う決裁ルートを保存 |
URLルーティング
?action=start → 起案フォーム
?action=approve&token= → 承認フォーム(決裁者向け)
?action=status&id= → 進捗確認(起案者向け)
?action=admin → 管理ダッシュボード(要権限)
?action=admin-detail → 管理詳細(要権限)
実装の核心部分
1. 設定シートで組織固有値を分離する
config.gs のキー定数はこうなっている。
const CONFIG_KEYS = {
ORG_NAME: '組織名',
ORG_CODE: '組織コード', // 稟議IDのプレフィックス(例: SHU)
ADMIN_DOMAIN: '管理者ドメイン',
EXTRA_ADMIN_EMAIL: '追加管理者メール',
NOTIFY_EMAIL: '通知先メール',
TOKEN_EXPIRY_DAYS: 'トークン有効期限(日)',
// ...
};
コード内で組織名が必要になる場所は全部 getConfig(CONFIG_KEYS.ORG_NAME) を呼ぶ。設定シートに値が入っていなければエラーを出す。デフォルト値が妥当な項目はフォールバックを持たせる。
function getConfig(key, defaultValue) {
const config = loadConfig_();
const val = config[key];
if (val === undefined || val === '') {
if (defaultValue !== undefined) return String(defaultValue);
throw new Error(`設定値 [${key}] が未設定です。設定シートを確認してください。`);
}
return val;
}
setupConfigSheet() をメニューから1回実行すると設定シートが自動作成される。新組織に展開するときはこれを実行してシートに値を入れるだけ。
2. スプレッドシートを純粋なDBとして扱う
列インデックスを定数で管理し、セル座標への直書きをなくした。
const AC = { // 起案一覧シートの列(1始まり)
ID: 1,
CREATED_AT: 2,
TITLE: 3,
CONTENT: 5,
STATUS: 11,
ARCHIVE_URL: 13,
// ...
};
// ステータス更新は必ずこの関数を通す
function updateApprovalStatus(approvalId, status, archiveUrl) {
const sheet = getApprovalSheet_();
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][AC.ID - 1]) === approvalId) {
sheet.getRange(i + 1, AC.STATUS).setValue(status);
if (archiveUrl) sheet.getRange(i + 1, AC.ARCHIVE_URL).setValue(archiveUrl);
return;
}
}
}
「K列に書けばステータスが変わる」という作りをやめた。関数を通じてのみ変更が行われる。シートを直接編集しても壊れにくい。
3. 承認フローの制御
起案フォームを送信すると submitApprovalRequest() が走る。
function submitApprovalRequest(formData) {
const lock = LockService.getDocumentLock();
lock.waitLock(15000); // 同時送信の重複防止
try {
validateFormData_(formData);
const approvalId = generateApprovalId();
insertApproval({ id: approvalId, ...formData });
insertApprovers(approvalId, formData.approvers);
// 第1順位の決裁者にメールを送る
const firstOrder = Math.min(...approvers.map(a => a.order));
issueTokensAndNotify_(approvalId, firstOrder);
return { success: true, approvalId };
} finally {
lock.releaseLock();
}
}
LockService は複数人が同時に起案したときの稟議ID重複を防ぐ。
承認が完了したら次の順位へ自動的に進む。
// 同順位グループの全員が回答済みか確認
const pendingInGroup = approvers.filter(
a => a.order === approverOrder && a.result === RESULT.PENDING
);
if (pendingInGroup.length > 0) {
return { success: true, status: 'waiting_parallel' };
}
// 次の順位へ
const nextOrder = Math.min(...approvers
.filter(a => a.result === RESULT.PENDING && a.order > approverOrder)
.map(a => a.order));
issueTokensAndNotify_(approvalId, nextOrder);
同じ順位番号を複数人に振れば並列承認になる。「部長と副部長に同時に送って両方の承認を得てから次へ」という使い方ができる。
4. トークンで承認リンクを安全にする
メールのリンクには UUID のトークンが付く。PropertiesService に保存する。
function generateApprovalToken(approvalId, approverOrder, approverEmail, action) {
const token = Utilities.getUuid();
const payload = JSON.stringify({
approvalId, approverOrder, approverEmail, action,
expiresAt: getExpiryDate_().toISOString(),
usedAt: null, // 使用済みマーク用
});
PropertiesService.getScriptProperties().setProperty('token_' + token, payload);
return token;
}
検証時は有効期限と使用済みを両方チェックする。
function verifyToken(token) {
const raw = PropertiesService.getScriptProperties().getProperty('token_' + token);
if (!raw) return { valid: false, reason: 'not_found' };
const data = JSON.parse(raw);
if (data.usedAt) return { valid: false, reason: 'used' };
if (new Date(data.expiresAt) < new Date()) return { valid: false, reason: 'expired' };
return { valid: true, data };
}
1回使用すると usedAt が書き込まれ、同じリンクを再クリックしても「このリンクはすでに使用済みです」と表示される。二重クリックを防げる。
期限切れトークンは毎日AM3時に時間駆動トリガーで自動削除する。PropertiesService の容量節約のため。
ハマったポイント
決裁者テンプレートのページ遷移
「テンプレートを保存する」ページを別のGAS Webアプリページとして作ったのだが、ページ間でデータを渡す方法が問題になった。
GAS WebアプリはURL パラメータを doGet(e.parameter) で受け取るが、大量の決裁者データをURLパラメータに載せるのは無理だ。
解決策:テンプレートデータをいったん PropertiesService に一時保存して、キーだけをURLパラメータで渡すようにした。
function storeTempApprovers(approvers) {
const key = Utilities.getUuid().replace(/-/g, '');
PropertiesService.getScriptProperties()
.setProperty('tmp_' + key, JSON.stringify(approvers));
return { success: true, key };
}
受け取り側のページで tmp_ キーを読んで削除する。一時キャッシュとして使う感じ。
格納先の動的追加
当初、格納先(完了後のDriveフォルダ)を設定シートに1件だけ書く設計にしていた。が、「研究費の種類によって格納先が違う」という要件が出てきた。
別の「格納先」シートを作り、行を追加するだけで起案フォームの選択肢が増える設計に変えた。コードを変更せずにシートの行を増やすだけで拡張できる。
function getStorageDestinations() {
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('格納先');
return sheet.getDataRange().getValues()
.filter(row => row[0] && row[1])
.map(row => ({ name: String(row[0]).trim(), url: String(row[1]).trim() }));
}
汎用化のメリットが出た部分
決裁者テンプレート機能
「総務課の標準決裁ルート(係長 → 課長 → 部長)」をテンプレートとして保存できる。起案フォームでテンプレートを選ぶと決裁者リストが自動で展開される。
組織ごとによく使うルートが違う。これをハードコードせずにシートで管理できるのは汎用化の恩恵だ。
名簿シート連携
「名簿」シートに組織のメンバーを登録しておくと、起案フォームの「名簿から選ぶ」ボタンでメール入力ミスを防げる。名簿シートの列順を決裁者テンプレートシートと同じにしてあるため、名簿からそのままコピペでテンプレートを作れる。
まとめ
電子決裁システムの汎用化でやったことを整理する。
- 設定シート集約: 組織固有値をコードに書かず、スプレッドシートの1シートに集める
- スプレッドシートDB化: セル座標への直書きをやめ、関数を通じたCRUD操作に統一
- HTMLフォーム化: 承認操作をブラウザページで完結させ、スプレッドシートを触らせない
- UUIDトークン: 有効期限・使い切り方式で二重クリックとなりすましを防ぐ
- 並列承認対応: 同一順位番号で複数人を同時決裁者にできる
「設定シート1枚で他組織に展開できる」を最初に設計原則として決めたのが良かった。あとから汎用化しようとすると、コードの随所に組織固有値が染み出していて直すのが大変になる。
Google Workspaceで動いている組織なら、GASは「今日から動いて5年後も誰かが保守できる」という条件を満たす数少ない選択肢だと思っている。
関連記事: