2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GASとCrowdLog APIで、チームの勤怠入力を自動チェックしてSlackに爆速通知する by Gemini

Last updated at Posted at 2025-07-11

はじめに

「今日の工数、入力したっけ...?」
「〇〇さん、昨日の工数入力忘れてますよ〜」

エンジニアの皆さん、こんなやり取りに心当たりはありませんか?
日々の開発業務に追われていると、ついつい工数の入力を忘れてしまったり、チームメンバーの入力状況を確認するのが面倒になったりしますよね。

そこで今回は、工数・勤怠管理サービス「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: 日本の祝日判定に利用し、休日の通知をスキップします。

仕組みの解説

全体の処理フローは以下のようになっています。

  1. トリガーのセットアップ(手動): 最初にcreateTrigger関数を毎日深夜に実行するようGASでトリガーを設定します。
  2. トリガーの動的作成 (trigger.js): createTriggerが実行されると、その日が日本の祝日カレンダーを考慮した「平日」かどうかを判定します。
  3. 平日であれば、既存の通知トリガーを一度すべて削除し、その日の11時22時に通知関数を実行する新しいトリガーを動的に作成します。
  4. 勤怠チェック&通知 (notice.js): 指定時刻になると、トリガーが各チームのチェック関数を実行します。
  5. 設定に基づき処理 (TEAMS_CONFIG): TEAMS_CONFIGという設定オブジェクトから、対象チームのメンバーリストやSlackのWebhook URLを取得します。
  6. APIでデータ取得 (callCrowdLogApi): CrowdLog APIを叩き、対象メンバーのその日の作業時間を取得します。
  7. メッセージ生成&通知: 取得したデータを基に、ステータスを盛り込んだメッセージを生成し、Slackに通知します。

コードのハイライト

1. 柔軟なチーム設定 (notice.js)

リファクタリングにより、チームの追加やメンバーの変更、通知先Slackチャンネルの変更が、このTEAMS_CONFIGオブジェクトを編集するだけで完結するようにしました。メンテナンス性が格段に向上しています。

notice.js
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関数に共通化されています。これにより、コードの重複がなくなり、非常にスッキリしました。

notice.js
/**
 * チームの入力状況を確認し、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)

このスクリプトの心臓部です。毎日実行されるこの関数が、カレンダーをチェックして「今日が平日か」を判断し、平日であればその日限りの通知トリガーを自動で作成します。これにより、休日に不要な通知が飛ぶのを防いでいます。

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の準備

  1. Googleスプレッドシートを新規作成します。
  2. 拡張機能 > Apps Script を選択してGASエディタを開きます。
  3. (推奨)ローカル環境で開発するために clasp をセットアップし、本記事で紹介したソースコードをデプロイします。

Step 2: スクリプトプロパティの設定

  1. GASエディタの左メニューから プロジェクトの設定 (歯車アイコン) をクリックします。
  2. スクリプト プロパティ のセクションで スクリプト プロパティを追加 をクリックします。
  3. 以下のプロパティを設定します。
    • プロパティ: CROWD_LOG_TOKEN
    • : あなたのCrowdLogパーソナルアクセストークン

Step 3: notice.js のカスタマイズ

notice.js を開き、TEAMS_CONFIG オブジェクトをあなたのチーム構成に合わせて編集します。

  • チーム名 (WEB1, WEB2 など)
  • 表示名 (displayName)
  • SlackのIncoming Webhook URL (slackWebhookUrl)
  • メンバーのリスト (users)

Step 4: トリガーの設定

最後に、この自動化の起点となるトリガーを設定します。

  1. GASエディタの左メニューから トリガー (目覚まし時計アイコン) をクリックします。
  2. 右下の トリガーを追加 ボタンをクリックします。
  3. 以下の通りに設定し、保存します。
    • 実行する関数を選択: createTrigger
    • 実行するデプロイを選択: Head
    • イベントのソースを選択: 時間主導型
    • 時間ベースのトリガーのタイプを選択: 日付ベースのタイマー
    • 時刻を選択: 午前1時〜2時 (など、日付が変わった後の早い時間帯)

これで、毎日深夜に createTrigger が実行され、その日が平日であれば自動で通知トリガーがセットされるようになります。

まとめ

今回は、GASとCrowdLog API、Slackを連携させて、勤怠入力のチェックを自動化する仕組みを紹介しました。

このスクリプトのおかげで、

  • 確認作業の工数ゼロ: 面倒な目視確認が不要になりました。
  • 入力漏れの撲滅: リマインドが自動化され、入力忘れがほぼなくなりました。
  • 心理的負担の軽減: 「あの人に催促しなきゃ...」という小さなストレスから解放されました。

日々の定型業務は、GASやAPIを活用してどんどん自動化していきましょう!
この記事が、皆さんの業務効率化のヒントになれば幸いです。

ソース

抜粋したソースを添付します!

notice.js

// =============================================
// 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');
}
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?