Back to Blog
GASGoogle Apps Script業務自動化大学電子決裁稟議Google Workspace

GASで電子稟議システムを作り直した話【スプレッドシート依存をやめてWebアプリ化した】

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年後も誰かが保守できる」という条件を満たす数少ない選択肢だと思っている。


関連記事:

Share:
View all posts