はじめに
「今日の工数、入力したっけ...?」
「〇〇さん、昨日の工数入力忘れてますよ〜」
エンジニアの皆さん、こんなやり取りに心当たりはありませんか?
日々の開発業務に追われていると、ついつい工数の入力を忘れてしまったり、チームメンバーの入力状況を確認するのが面倒になったりしますよね。
そこで今回は、工数・勤怠管理サービス「CrowdLog」とGoogle Apps Script (GAS) を連携させ、チームの工数入力状況を自動でチェックし、Slackに通知する仕組みを構築した事例を紹介します。
この仕組みを導入することで、面倒な確認作業から解放され、チーム全体の入力漏れを劇的に減らすことができました。
完成したもの
平日の決まった時間(今回は11時と22時)に、以下のような通知がSlackに届きます。
【通知イメージ】
--- 2025-07-11 技術開発本部 CrowdLog入力状況 ---
✅ 鈴木一郎 (M158) は入力完了しています。(8時間)
❌ 佐藤花子 (M171) はまだ入力していません。
⚠️ 高橋健太 (M135) は入力工数不足です。(3時間)
...
⚠️ WEB1チームは、まだ今日のCrowdLog入力を完了していないメンバーがいます。ファイト!
主な機能は以下の通りです。
- 定時自動チェック: 毎日決まった時刻に自動で入力状況をチェック。
- ステータス別表示: 「完了✅」「工数不足⚠️」「未入力❌」を分かりやすく表示。
- チーム単位での通知: チームごとに通知先チャンネルやメンバーを柔軟に設定可能。
- 休日対応: 土日・祝日には通知が飛ばないように自動で制御。
- 前営業日のチェック: 翌日の午前中に、前営業日の入力漏れもチェック可能。
技術スタック
- Google Apps Script (GAS): すべての処理を記述するメインエンジン。
- CrowdLog API: 勤怠データを取得するために利用。
- Slack Incoming Webhooks: チェック結果をSlackに通知するために利用。
- Google Calendar API: 日本の祝日判定に利用し、休日の通知をスキップします。
仕組みの解説
全体の処理フローは以下のようになっています。
-
トリガーのセットアップ(手動): 最初に
createTrigger
関数を毎日深夜に実行するようGASでトリガーを設定します。 -
トリガーの動的作成 (
trigger.js
):createTrigger
が実行されると、その日が日本の祝日カレンダーを考慮した「平日」かどうかを判定します。 - 平日であれば、既存の通知トリガーを一度すべて削除し、その日の11時と22時に通知関数を実行する新しいトリガーを動的に作成します。
-
勤怠チェック&通知 (
notice.js
): 指定時刻になると、トリガーが各チームのチェック関数を実行します。 -
設定に基づき処理 (
TEAMS_CONFIG
):TEAMS_CONFIG
という設定オブジェクトから、対象チームのメンバーリストやSlackのWebhook URLを取得します。 -
APIでデータ取得 (
callCrowdLogApi
): CrowdLog APIを叩き、対象メンバーのその日の作業時間を取得します。 - メッセージ生成&通知: 取得したデータを基に、ステータスを盛り込んだメッセージを生成し、Slackに通知します。
コードのハイライト
1. 柔軟なチーム設定 (notice.js
)
リファクタリングにより、チームの追加やメンバーの変更、通知先Slackチャンネルの変更が、このTEAMS_CONFIG
オブジェクトを編集するだけで完結するようにしました。メンテナンス性が格段に向上しています。
const TEAMS_CONFIG = {
'WEB1': {
displayName: 'WEB1チーム',
slackWebhookUrl: 'https://hooks.slack.com/services/xxxxx/xxxxx/xxxxx',
users: [
{ code: 'M161', id: '11111', name: '鈴木一郎' },
// ...メンバー...
],
},
'WEB2': {
displayName: 'WEB2チーム',
slackWebhookUrl: 'https://hooks.slack.com/services/yyyyy/yyyyy/yyyyy',
users: [
{ code: 'M113', id: '2222', name: '佐藤花子' },
// ...メンバー...
],
},
// ...他のチーム設定...
};
2. 処理の共通化 (notice.js
)
各チームのチェック処理は、新しく作成したcheckTeamInputAndNotify
関数に共通化されています。これにより、コードの重複がなくなり、非常にスッキリしました。
/**
* チームの入力状況を確認し、Slackに通知する共通関数
* @param {string} teamKey TEAMS_CONFIGのキー
* @param {string} date 対象の日付 (YYYY-MM-DD)
*/
function checkTeamInputAndNotify(teamKey, date) {
const teamConfig = TEAMS_CONFIG[teamKey];
if (!teamConfig) {
Logger.log(`チーム "${teamKey}" が設定に見つかりません。`);
return;
}
const { users, slackWebhookUrl, displayName } = teamConfig;
const inputStatus = checkCrowdLogInputStatus(users, date);
const message = formatSlackMessage(displayName, date, inputStatus, users);
sendSlackNotification(slackWebhookUrl, message);
}
// 各チーム用のトリガー関数
function checkWeb1TeamTodayInput() {
checkTeamInputAndNotify('WEB1', Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd'));
}
3. 動的なトリガー作成 (trigger.js
)
このスクリプトの心臓部です。毎日実行されるこの関数が、カレンダーをチェックして「今日が平日か」を判断し、平日であればその日限りの通知トリガーを自動で作成します。これにより、休日に不要な通知が飛ぶのを防いでいます。
function createTrigger() {
// --- 実行日の判定 ---
const today = new Date();
const dayOfWeek = today.getDay();
// 1. 土日判定
if (dayOfWeek === 0 || dayOfWeek === 6) {
console.log('本日は土日のため、トリガー作成をスキップしました。');
return;
}
// 2. 祝日判定
const calendarId = 'ja.japanese#holiday@group.v.calendar.google.com';
const calendar = CalendarApp.getCalendarById(calendarId);
const events = calendar.getEventsForDay(today);
if (events.length > 0) {
console.log('本日は祝日のため、トリガー作成をスキップしました。');
return;
}
// --- 既存トリガーの削除 ---
const allTriggers = ScriptApp.getProjectTriggers();
for (const trigger of allTriggers) {
const handlerFunction = trigger.getHandlerFunction();
if (handlerFunction === 'checkEngineerTodayInput' || handlerFunction === 'checkEngineerYesterDayInput') {
ScriptApp.deleteTrigger(trigger);
}
}
// --- 新しいトリガーの作成 ---
// ... (指定時刻に通知関数を実行するトリガーを作成する処理) ...
}
導入方法
Step 1: Google Apps Scriptの準備
- Googleスプレッドシートを新規作成します。
-
拡張機能
>Apps Script
を選択してGASエディタを開きます。 - (推奨)ローカル環境で開発するために
clasp
をセットアップし、本記事で紹介したソースコードをデプロイします。
Step 2: スクリプトプロパティの設定
- GASエディタの左メニューから
プロジェクトの設定
(歯車アイコン) をクリックします。 -
スクリプト プロパティ
のセクションでスクリプト プロパティを追加
をクリックします。 - 以下のプロパティを設定します。
-
プロパティ:
CROWD_LOG_TOKEN
- 値: あなたのCrowdLogパーソナルアクセストークン
-
プロパティ:
Step 3: notice.js
のカスタマイズ
notice.js
を開き、TEAMS_CONFIG
オブジェクトをあなたのチーム構成に合わせて編集します。
- チーム名 (
WEB1
,WEB2
など) - 表示名 (
displayName
) - SlackのIncoming Webhook URL (
slackWebhookUrl
) - メンバーのリスト (
users
)
Step 4: トリガーの設定
最後に、この自動化の起点となるトリガーを設定します。
- GASエディタの左メニューから
トリガー
(目覚まし時計アイコン) をクリックします。 - 右下の
トリガーを追加
ボタンをクリックします。 - 以下の通りに設定し、保存します。
-
実行する関数を選択:
createTrigger
-
実行するデプロイを選択:
Head
-
イベントのソースを選択:
時間主導型
-
時間ベースのトリガーのタイプを選択:
日付ベースのタイマー
-
時刻を選択:
午前1時〜2時
(など、日付が変わった後の早い時間帯)
-
実行する関数を選択:
これで、毎日深夜に createTrigger
が実行され、その日が平日であれば自動で通知トリガーがセットされるようになります。
まとめ
今回は、GASとCrowdLog API、Slackを連携させて、勤怠入力のチェックを自動化する仕組みを紹介しました。
このスクリプトのおかげで、
- 確認作業の工数ゼロ: 面倒な目視確認が不要になりました。
- 入力漏れの撲滅: リマインドが自動化され、入力忘れがほぼなくなりました。
- 心理的負担の軽減: 「あの人に催促しなきゃ...」という小さなストレスから解放されました。
日々の定型業務は、GASやAPIを活用してどんどん自動化していきましょう!
この記事が、皆さんの業務効率化のヒントになれば幸いです。
ソース
抜粋したソースを添付します!
// =============================================
// CONFIGURATION
// =============================================
const CROWDLOG_API_BASE_URL = 'https://xxx.xxx.xxx';
const PERSONAL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('CROWD_LOG_TOKEN');
const SLACK = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
/**
* チーム設定
* - `users`: 通知対象のユーザーリスト
* - `slackWebhookUrl`: 通知先のSlack Webhook URL
* - `displayName`: Slack通知時に表示されるチーム名
*/
const TEAMS_CONFIG = {
'WEB1': {
displayName: 'WEB1チーム',
slackWebhookUrl: SLACK,
users: [
{ code: 'M161', id: '1111', name: '鈴木一郎' },
],
},
'WEB2': {
displayName: 'WEB2チーム',
slackWebhookUrl: SLACK,
users: [
{ code: 'M113', id: '2222', name: '佐藤花子' },
],
},
};
// =============================================
// GENERIC HELPER FUNCTIONS
// =============================================
/**
* CrowdLog APIを呼び出し、レスポンスをJSONとして返します。
* @param {string} endpoint APIのエンドポイントパス(クエリパラメータ含む)
* @returns {Object|null} APIレスポンスのJSONデータ、またはエラーの場合はnull
*/
function callCrowdLogApi(endpoint) {
const apiUrl = CROWDLOG_API_BASE_URL + endpoint;
const options = {
'method': 'GET',
'headers': {
'Accept': 'application/json',
'Authorization': 'Bearer ' + PERSONAL_ACCESS_TOKEN
},
'muteHttpExceptions': true
};
try {
const response = UrlFetchApp.fetch(apiUrl, options);
const statusCode = response.getResponseCode();
const responseBody = response.getContentText();
if (statusCode === 200) {
return JSON.parse(responseBody);
} else {
Logger.log(`APIエラー: ステータスコード ${statusCode}, レスポンス: ${responseBody}`);
return null;
}
} catch (e) {
Logger.log(`スクリプトエラーが発生しました: ${e.toString()}`);
return null;
}
}
/**
* 指定されたユーザーリストのCrowdLog入力状況を確認します。
* @param {Array<Object>} users ユーザーオブジェクトの配列
* @param {string} date 検知したい日付 (YYYY-MM-DD形式)
* @returns {Object} 各ユーザーの入力完了ステータスを含むオブジェクト
*/
function checkCrowdLogInputStatus(users, date) {
if (!users || users.length === 0) {
Logger.log('チェック対象のユーザーが指定されていません。');
return {};
}
const userIdsParam = users.map(user => `user_ids=${user.id}`).join('&');
const perPage = users.length;
const endpoint = `/work_actual/by_user/daily?since=${date}&until=${date}&${userIdsParam}&per_page=${perPage}`;
Logger.log(`${date} のCrowdLog入力を確認中... 対象ユーザー: ${users.map(u => u.name).join(', ')}`);
const data = callCrowdLogApi(endpoint);
const inputStatus = {};
users.forEach(user => {
inputStatus[user.code] = { 'entry': false, 'hours': 0 };
});
if (data && data.work_actual && data.work_actual.length > 0) {
data.work_actual.forEach(entry => {
if (inputStatus.hasOwnProperty(entry.code)) {
inputStatus[entry.code]['entry'] = true;
inputStatus[entry.code]['hours'] = entry.data[0].hours;
}
});
}
return inputStatus;
}
/**
* Slack通知用のメッセージを生成します。
* @param {string} teamName 表示用のチーム名
* @param {string} date 対象の日付
* @param {Object} inputStatus 入力状況データ
* @param {Array<Object>} users ユーザーリスト
* @returns {string} Slackに送信するメッセージ
*/
function formatSlackMessage(teamName, date, inputStatus, users) {
let message = `---:クラウドログ: ${date} ${teamName} CrowdLog入力状況 :クラウドログ: ---`;
let allCompleted = true;
users.forEach(user => {
const status = inputStatus[user.code];
if (status.entry && status.hours >= 4) {
message += `\n✅ ${user.name} (${user.code}) は入力完了しています。(${status.hours}時間)`
} else if (status.entry) {
message += `\n⚠️ ${user.name} (${user.code}) は入力工数不足です。(${status.hours}時間)`
allCompleted = false;
} else {
message += `\n❌ ${user.name} (${user.code}) はまだ入力していません。`
allCompleted = false;
}
});
const dateString = date === getPreviousWorkday() ? ` ${date}` : '今日';
if (allCompleted) {
message += `\n🎉 ${teamName}全員が${dateString}のCrowdLog入力を完了しています!お疲れ様でした!`;
} else {
message += `\n⚠️ ${teamName}には、まだ${dateString}のCrowdLog入力を完了していないメンバーがいます。ファイト!`;
}
return message;
}
/**
* Slackに通知を送信します。
* @param {string} webhookUrl 送信先のWebhook URL
* @param {string} message 送信するメッセージ
*/
function sendSlackNotification(webhookUrl, message) {
if (!webhookUrl) {
Logger.log('Slack Webhook URLが設定されていません。通知をスキップします。');
return;
}
UrlFetchApp.fetch(webhookUrl, {
"method": "post",
"contentType": "application/json",
"payload": JSON.stringify({ "text": message }),
});
}
/**
* チームの入力状況を確認し、Slackに通知する共通関数
* @param {string} teamKey TEAMS_CONFIGのキー
* @param {string} date 対象の日付 (YYYY-MM-DD)
*/
function checkTeamInputAndNotify(teamKey, date) {
const teamConfig = TEAMS_CONFIG[teamKey];
if (!teamConfig) {
Logger.log(`チーム "${teamKey}" が設定に見つかりません。`);
return;
}
const { users, slackWebhookUrl, displayName } = teamConfig;
const inputStatus = checkCrowdLogInputStatus(users, date);
const message = formatSlackMessage(displayName, date, inputStatus, users);
sendSlackNotification(slackWebhookUrl, message);
}
// =============================================
// EXECUTABLE FUNCTIONS (FOR TRIGGERS)
// =============================================
function checkWeb1TeamTodayInput() {
checkTeamInputAndNotify('WEB1', Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd'));
}
function checkWeb2TeamTodayInput() {
checkTeamInputAndNotify('WEB2', Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd'));
}
function checkWeb1YesterDayInput() {
checkTeamInputAndNotify('WEB1', getPreviousWorkday());
}
/**
* 前の営業日(土日・祝日を除く平日)の日付を 'yyyy-MM-dd' 形式で取得します。
* @returns {string} 前の営業日の日付(例: '2025-07-10')
*/
function getPreviousWorkday() {
const holidayCalendar = CalendarApp.getCalendarById('ja.japanese#holiday@group.v.calendar.google.com');
const date = new Date();
while (true) {
date.setDate(date.getDate() - 1);
const dayOfWeek = date.getDay();
const isWeekend = (dayOfWeek === 0 || dayOfWeek === 6);
if (!isWeekend) {
const holidays = holidayCalendar.getEventsForDay(date);
if (holidays.length === 0) {
break;
}
}
}
return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd');
}