「あの人、退職日が2週間前なのにGoogleアカウントがまだ生きてる」——そういう報告がある日ふと上がってきたとき、情シス担当として正直かなり焦りました。
申請はジョブカンワークフローで受け付けているのに、なぜ見落とすのか。原因はシンプルで、「申請が承認されたことをIT側が能動的に拾いにいく仕組みがなかった」からです。
この記事では、ジョブカンの申請データをGASで取得し、SmartHRの従業員情報と突き合わせながら在籍者DBを自動更新し、ステータス変化をSlackへ即通知する仕組みを作った経緯と、実装でハマった箇所を再現手順とともに共有します。
背景
100名規模のIPO準備フェーズにある会社で、情シス担当者1〜2名が入退社・休職・業務委託の受け入れをすべて手作業で管理していました。
ジョブカンワークフローで申請自体は受け付けているものの、担当者が毎日申請一覧を開いて確認するという運用が前提になっており、多忙な日は確認が後回しになることがありました。
さらに、業務委託スタッフが増えるにつれて「誰が今在籍しているか」を把握するマスタが散在し始めます。「ジョブカンには載っているがSmartHRには未登録」「Slackにはいるが在籍者DBには存在しない」——こういった状態が常態化すると、棚卸しのたびに大量の突き合わせ作業が発生します。
作ったもの
GASとAppSheet(Googleが提供するノーコードアプリ開発ツール)を組み合わせた従業員ライフサイクル管理システムです。
以下のデータフローで動作します。
ジョブカンワークフロー API
│
├─ 入社申請 / 退職申請 / 休職・復職申請
└─ 業務委託申請(入場 / 退場)
│
▼
各申請の一覧シート(Googleスプレッドシート)
│
├─ 入社.js / 退職.js / 休職復職申請.js
└─ 業務委託入場.js / 業務委託退場.js
│
▼
在籍者DB(Googleスプレッドシート)◀─── SmartHR API(部署・役職・社員番号)
│
│ Slack通知.js(1分間隔でステータス監視)
│
▼
Slack通知
├─ 初回通知(ステータス変更検知時)
├─ 週次リマインド(毎週月曜 9:00)
└─ Google削除アラート(退職2ヶ月後、平日 14:00)
技術構成はシンプルです。
| 項目 | 内容 |
|---|---|
| 実行環境 | Google Apps Script |
| データストア | Googleスプレッドシート |
| 操作UI | AppSheet(Googleが提供するノーコードアプリ開発ツール。スプレッドシートをデータソースにアプリを作成できる) |
| 外部連携 | ジョブカンワークフロー API(β版、仕様変更リスクあり)、SmartHR API、Slack Incoming Webhook |
| ローカル開発 | clasp(GASをローカルで開発・デプロイするためのGoogle公式CLIツール。npmでインストールしてGitで管理できる) |
外部サービスを新たに契約する必要はなく、すでに会社が使っているSaaSを繋ぐだけです。
ひとつ注意点を先に書いておきます。ジョブカンワークフローAPIは現時点でβ版です(出典:ジョブカンAPI公式ドキュメント)。β版は仕様変更によって突然動かなくなるリスクがあるため、プロダクション導入の際はその点を織り込んでおいてください。
また、SmartHR APIとの連携(部署・役職・社員番号の取得)については、エンドポイントや認証フローを含む実装詳細を別稿で解説する予定です。本稿では最低限として、従業員一覧の取得に GET /crews、部署情報の取得に GET /departments を使用していることだけ記載しておきます。SmartHR APIはSmartHR for Developersから利用可能な公開APIで、公式ドキュメントをそちらから参照できます。
やったこと
ステータス遷移の設計
最初にやるべきことは、「人がどう動くか」をステータスとして定義することです。雇用形態によってフローが異なります。
正社員・契約社員の入社フロー
(新規)→ 入社処理中(IT)→ 入社処理中(労務)→ 在籍中
退職フロー
在籍中 → 退職処理中(IT)→ 退職処理中(労務)→ 退職済
業務委託フロー
(新規)→ 業務委託入場処理中(IT)→ 在籍中
在籍中 → 業務委託退場処理中(IT)→ 退職済
IT処理が完了したあとのステータス更新(例:「入社処理中(IT)→ 入社処理中(労務)」)は、AppSheetで作った操作画面から手動で行う設計にしています。完全自動化ではなく、IT・労務それぞれの確認ステップを意図的に残した設計です。「自動化しすぎると責任の所在が曖昧になる」という判断からです。AppSheetはスプレッドシートをデータソースとして直接参照できるため、GASが更新した在籍者DBをそのまま画面に表示・編集できます。AppSheet側の詳細なUI設定については本稿では割愛します。
ジョブカンAPIからのデータ取得
以下は、ジョブカンワークフロー APIから申請一覧を取得するコードの骨格です。10件ずつバッチでリクエストを分割しています。
なお、ジョブカンAPIは1リクエストあたり最大100件の取得に対応しています(出典:ジョブカンAPI公式ドキュメント)。コード内の LIMIT=10 は初期構築時のデバッグ用の値です。本番運用では LIMIT=100 に変更することで、同じデータ量を1/10のリクエスト数で取得できます。APIレート制限との兼ね合いもありますが、まずは LIMIT=100 で試してみることをお勧めします。
APIトークンの管理について:トークンをソースコードに直書きすることは避けてください。GASの PropertiesService(スクリプトプロパティ)に格納するのがベストプラクティスです。GASエディタの「プロジェクトの設定」→「スクリプト プロパティ」からキーと値を登録でき、GitHubで管理していてもトークンが漏洩しません。
// Config.js の一部
// APIトークンはソースコードに書かず、スクリプトプロパティから取得する
const JOBCAN_API_TOKEN = PropertiesService.getScriptProperties().getProperty('JOBCAN_API_TOKEN');
// ジョブカン一覧.js の一部
// バッチ処理で申請データを取得し、スプレッドシートに出力する
function exportAllFormsFast() {
const startTime = Date.now();
const LIMIT = 10;
let offset = 0;
while (true) {
// GASの6分実行制限を考慮し、5分30秒で中断する
if (Date.now() - startTime > 5.5 * 60 * 1000) {
console.log('実行時間上限に達したため中断');
break;
}
const response = fetchJobkanForms(offset, LIMIT);
if (!response || response.length === 0) break;
writeToSheet(response);
offset += LIMIT;
Utilities.sleep(750); // APIレート制限対策(ジョブカンAPIは1時間5,000リクエスト上限 ≒ 1.388req/秒。最小間隔720ms、安全マージンとして750msを設定)
}
}
LIMIT の値を変えると1リクエストあたりの取得件数を調整できます。sleep の値はジョブカンAPI公式の制限(1アクセストークンあたり1時間5,000リクエスト、出典:ジョブカンAPI公式ドキュメント)から逆算しています。1.388…リクエスト/秒(5,000リクエスト÷3,600秒)以下に収めるには最小720ms以上の間隔が必要なため、安全マージンを含めて750ms以上を設定するのが無難です。sleep(200) や sleep(500) では上限を超えるケースがあるので注意してください。
在籍者DBへの同期
在籍者DBは以下の主要カラムで構成したGoogleスプレッドシートです。コード中でキー名として使うため、列名と完全に一致させることが前提です。
| カラム名 | 内容 |
|---|---|
| 社員番号_在籍者DB | 社員番号(業務委託は IT-YYYYMMDD-XXX 形式で自動採番) |
| 氏名_在籍者DB | 姓名(フルネーム) |
| メールアドレス_在籍者DB | 会社メールアドレス |
| 雇用形態_在籍者DB | 正社員 / 契約社員 / 業務委託 など |
| ステータス_在籍者DB | 在籍中 / 入社処理中(IT)/ 退職処理中(IT)/ 退職済 など |
| 入社日_在籍者DB | 入社日または業務委託の入場日 |
| 退職日_在籍者DB | 退職日または業務委託の退場予定日 |
| 通知済フラグ_在籍者DB | Slack初回通知の重複送信防止フラグ(TRUE/FALSE) |
入社申請を在籍者DBに反映するときは、既存レコードとの照合が必要です。単純な氏名一致では同姓同名や表記ゆれに対応できないため、Utils.js に共通のマッチングロジックを実装しました。
在籍者DBの全レコードからインデックスを構築し、以下の優先順位で検索します。
複合キー(社員番号+メール) → 社員番号 → メールアドレス → 氏名
// Utils.js の一部
// 在籍者DBのレコードを4種類のキーでインデックス化し、優先順位順に検索する
function buildIndex(rows) {
const byCompositeKey = {};
const byEmployeeId = {};
const byEmail = {};
const byName = {};
rows.forEach((row, i) => {
const empId = normalize(row['社員番号_在籍者DB']);
const email = normalizeEmail(row['メールアドレス_在籍者DB']);
const name = normalizeName(row['氏名_在籍者DB']);
if (empId && email) byCompositeKey[`${empId}::${email}`] = i;
if (empId) byEmployeeId[empId] = (byEmployeeId[empId] ?? []).concat(i); // ?? はV8ランタイム必須(現在のGASのデフォルト。旧Rhinoエンジンでは動作しない)
if (email) byEmail[email] = (byEmail[email] ?? []).concat(i);
if (name) byName[name] = (byName[name] ?? []).concat(i);
});
return { byCompositeKey, byEmployeeId, byEmail, byName };
}
上位のキーで1件ヒットすればそこで確定。0件なら次のキーへフォールバックし、2件以上ヒットした場合は警告としてスキップします。このロジックを最初にしっかり作ると、後から照合ミスが出たときも Utils.js を直すだけで全スクリプトに反映されます。
ハマったポイント
1. 氏名・メールアドレスの表記ゆれ地獄
一番時間を取られた部分です。ジョブカンとSmartHRで同じ人物のデータが微妙に異なる形式で入力されていました。
- メールアドレスに全角スペースが混入していた
- 氏名の姓と名の間のスペースが「全角」「半角」「スペースなし」でバラバラ
- 「派遣社員」「派遣スタッフ」「派遣」のように雇用形態の表記が統一されていない
これらをそのまま照合しようとすると、同一人物を別人として登録してしまいます。対処として、取得時にすべて正規化することにしました。
// Utils.js の正規化関数
// データ取得時にすべてこの関数を通すことで、表記ゆれを吸収する
// 社員番号用:NFKC正規化のみ。先頭ゼロは除去しない("0042" ≠ "42" と扱う)
function normalize(value) {
if (!value) return '';
return value.toString().normalize('NFKC').trim();
}
function normalizeEmail(email) {
if (!email) return '';
return email.toString()
.normalize('NFKC') // 全角英数字→半角に統一
.toLowerCase()
.replace(/\s+/g, ''); // スペースをすべて除去
}
function normalizeName(name) {
if (!name) return '';
return name.toString()
.normalize('NFKC')
.replace(/[\s ]/g, ''); // 全角・半角スペースを両方除去
}
NFKC(Unicode正規化形式の一種。全角英数字・全角スペース・半角カタカナといった文字を、対応する半角・標準形の文字へ変換します)正規化を通すと、全角英数字や全角スペースが半角に揃います。氏名はスペース除去だけに留めています。読み仮名まで一致させようとすると逆にマッチしにくくなるため、やりすぎ注意です。
2. GASの6分実行制限
GASには「1回の実行が6分を超えるとタイムアウトする」という制限があります(公式ドキュメントより)。ジョブカンのデータが数百件になると、1回のスクリプト実行で全件を処理しきれません。
対策は2つです。
- APIリクエストを10件ずつのバッチに分割する
- 5分30秒経過したら処理を中断し、次の定期実行に持ち越す
ただし、「処理の途中で切れた場合、どこまで取得済みかを記録していないと重複取得が起きる」という問題があります。今回はオフセット値をGASのプロパティストアに保存して再開できるようにしました。骨格はこういう形です。
// ジョブカン一覧.js の一部
// 中断時にオフセットを保存し、次回実行時に再開する
function exportAllFormsFastWithResume() {
const startTime = Date.now();
const LIMIT = 10; // デバッグ用。本番は LIMIT=100 推奨
const props = PropertiesService.getScriptProperties();
// 前回の中断位置から再開する(初回は 0)
let offset = Number(props.getProperty('jobkan_offset') ?? 0);
while (true) {
// 5分30秒経過で中断し、オフセットを保存してから終了
if (Date.now() - startTime > 5.5 * 60 * 1000) {
props.setProperty('jobkan_offset', String(offset)); // ← ループ内で更新してから保存
console.log(`中断。次回は offset=${offset} から再開`);
break;
}
const response = fetchJobkanForms(offset, LIMIT);
if (!response || response.length === 0) {
// 全件取得完了:オフセットをリセット
props.deleteProperty('jobkan_offset');
break;
}
writeToSheet(response);
offset += LIMIT; // ← ここでオフセットを進める
Utilities.sleep(750);
}
}
このログ管理は導入初期に軽視しがちですが、怠ると後でデータが二重登録されていることに気づいて慌てます。経験談です。
3. Slack通知の重複送信
1分ごとにステータスを監視していると、同じステータス変更を何度も通知してしまう問題が起きます。「田中さんが入社処理中になりました」というメッセージが毎分飛んでくるのは誰も幸せになりません。
解決策として、通知ログシートに「誰の、どのステータスへの変更通知を送ったか」を記録し、送信前にチェックするようにしました。
// Slack通知.js の重複チェック部分
// 通知ログシートを参照し、同じ通知が既に送信済みかどうかを確認する
// 通知ログシートの列構造: row[0] = 社員番号(rowId)、row[1] = ステータス
function isAlreadyNotified(logSheet, rowId, status) {
const logs = logSheet.getDataRange().getValues();
return logs.some(row =>
row[0] === rowId && row[1] === status
);
}
週次リマインドは「今週送ったかどうか」の判定を別途設けています。送信日の週番号と照合する方式です。通知ログは6ヶ月以上古いものを毎月1日に自動削除して肥大化を防いでいます。
4. 業務委託×元正社員のエッジケース
「以前に正社員として在籍し、退職後に業務委託として戻ってきた人」がいました。在籍者DBに既存レコードがあるため、新規追加するとデータが重複します。このケースを最初の設計に含めていなかったため、稼働後に気づいて追加対応しました。
// 業務委託入場.js の一部
// 退職済みの既存レコードがある場合は新規追加せず、雇用形態だけ更新する
if (existingRecord && existingRecord['ステータス_在籍者DB'] === '退職済') {
updateRow(existingRecord, {
雇用形態_在籍者DB: '業務委託', // カラム名と完全一致させること
ステータス_在籍者DB: '業務委託入場処理中(IT)'
});
} else {
// 新規の場合は IT-YYYYMMDD-XXX 形式で社員番号を自動生成して追加
// generateContractorId() の実装詳細は本稿では割愛
const newEmpId = generateContractorId();
insertRow({ ...newPersonData, 社員番号: newEmpId });
}
このロジックがないと、同一人物が「退職済」と「業務委託入場処理中」で二重登録されます。業務委託の受け入れが多い会社では、このパターンは意外と頻繁に発生します。
結果と学び
稼働後、入退社の見落としはゼロになりました。ステータスが変わった瞬間にSlackへ通知が飛ぶため、情シスが能動的にジョブカンの申請一覧を確認しに行く必要がなくなりました。週次リマインドのおかげで「処理中のまま放置」も防げています。
一方でコストもあります。GASのトリガーを10本以上設定するため、管理が複雑になります。GASには1スクリプトあたりトリガーを最大20本まで設定できる上限があり(出典:Google Apps Script Quotas)、累計実行時間のクォータも存在します。具体的には、コンシューマアカウント(@gmail.com)は90分/日、Google Workspaceアカウントは6時間/日と大きく異なります(出典:Google Apps Script Quotas)。1分間隔のトリガーを複数設定している場合、コンシューマアカウントでは1時間半でサイレントに止まる可能性があります。本番運用はGoogle Workspaceアカウントで動かすことを前提に設計してください。クォータ超過でサイレントに実行されないケースも起こりえるため、監視用のアラートと合わせて設計することを推奨します。また、ジョブカンやSmartHRのAPIに仕様変更が入ればスクリプト側の修正も必要です。「作って終わり」ではなく、SaaSのバージョンアップに追随する運用コストを見込んでおく必要があります。
つまり、この仕組みが一番ハマるのは「申請フローがジョブカンに統一されていて、人事マスタがSmartHRにある」という環境です。SaaSが整っている会社であれば、GASでの自動連携は費用対効果が高いといえるでしょう。逆に、申請がメールやSlackで飛んでくる運用のままだと、GASで繋ぎようがありません。まず申請フローの一元化が先決です。そこを飛ばして自動化しようとしても、ゴミが高速に回るだけです。
再現手順まとめ
- スプレッドシートの初期セットアップ:Googleスプレッドシートに2枚のシートを作成する。①「在籍者DB」シートは「在籍者DBへの同期」セクションの列定義(社員番号_在籍者DB・氏名_在籍者DB・メールアドレス_在籍者DB・雇用形態_在籍者DB・ステータス_在籍者DB・入社日_在籍者DB・退職日_在籍者DB・通知済フラグ_在籍者DB)を1行目に設ける。②「通知ログ」シートは1列目に「社員番号(rowId)」、2列目に「ステータス」を設定する。コード中のシート名参照は
Config.jsの定数と一致させること。 - clasp(GASをローカルで開発・デプロイするためのCLIツール)をインストールし、GASプロジェクトとローカルリポジトリを紐づける(clasp公式ドキュメント)
Config.jsにシート名・ステータス定義・日付フィルター(処理対象の基準日)を定数として定義するUtils.jsに正規化関数とマッチングロジックを実装する(表記ゆれ対策はここで集中管理するのがポイント)- 各同期スクリプト(入社.js / 退職.js / 業務委託入場.js など)を作成し、
Utils.jsの関数を呼び出す形で実装する Slack通知.jsに通知ログシートとの照合ロジックを入れ、重複送信を防ぐ- GASのトリガーを設定する(Slack通知は1分ごと、他は15〜30分ごとが目安)
- 初回テストは本番スプレッドシートではなくコピーを使って実施する
最初に Utils.js の正規化をしっかり作ることが、後々の品質を左右します。照合ミスが出たときも、正規化ロジックを直すだけで全スクリプトに反映されるためです。
コーポレートITのご相談はお気軽に
この記事で書いたような業務改善・自動化の設計から実装まで、DRASENASではコーポレートITの現場に寄り添った支援を行っています。 「まず相談だけ」でも大歓迎です。DRASENAS 公式サイトからお気軽にどうぞ。
御社の IT 部門、ここにあります。
「ITのことはあまりわからない」── そのような状態からで、まったく問題ございません。まずはお気軽にご相談ください。