勤怠の打刻率を改善したい!勤怠ツールとSlackを連携して行動を変化できた事例をご紹介

勤怠の打刻率を改善したい!勤怠ツールとSlackを連携して行動を変化できた事例をご紹介
この記事は最終更新日から約2年が経過しています。

出勤してまず最初に何をしますか?会社にお勤めの方であれば「勤怠の打刻」という方もきっと多いですよね。

アナグラムでも勤怠状況を把握し、健やかに業務を行えるように勤怠管理のシステムを導入していますが、打刻率が高まらないというのが課題のひとつでした。

また、総務部で毎月の勤怠の締め日に修正をお願いするのにも多くの手間がかかっており、定期的な呼びかけをするも、一向に改善はみられません。

そんな状況をアナグラムのテックチームとして解決できないか考え行き着いたのが「Slackから打刻できるようにする」です。

行動までのハードルを取り除く

なぜ毎日打刻できないのか。打刻に限りませんが、何かを新しく行うときに継続できない理由の多くは、その行動をするまでにハードルがあることがほとんどだと思います。

たとえば、運動をしたくて入会したスポーツジムが遠くて通わなくなってしまった、家に帰るとついついテレビを見てしまい読書が続けられないなど、身に覚えのある方も多いのではないでしょうか。

ツールを利用してWebで勤怠を管理している企業も多いと思いますが、わざわざ画面を開いて打刻するのは正直めんどうくさいものです。たったそれだけのこと、と思う方もいるかもしれませんが、原因の多くは一見ささいなハードルなのです。

勤怠ツールを毎日開くのは面倒くさいひとでも、仕事に必ず使うチャットツールやSlackなら基本的に開かない日はありませんよね。毎日開くツールから打刻することができれば、行動のためのハードルを減らすことができ、打刻率も改善するのではないかと考えました。

勤怠の打刻漏れを80%削減

そうして画像のように、Slackのチャンネルに投稿をすることで、毎日弊社のマスコット、グラムくんが打刻をしてくれるようにしました。その結果、毎月の打刻漏れは導入前に比べてなんと80%削減できました!

※ちなみに少しでも楽しく打刻してもらえるよう、こっそり毎朝おみくじをしてくれます。

今回は、そんなSlackと勤怠ツールを連携をGoogle Apps Script(GAS)で実装する方法を解説します。(エンジニア向けの記事となっています。)

勤怠ツールについてはアナグラムで使用しているAKASHIを用いた例を挙げますが、基本的にはAPIを利用するだけなので、API経由で打刻できるツールであれば他ツールでも使うことができます。

GASについてはこちらをご参考ください。

全体の流れ

まず全体の流れを解説します。

今回使うツールは下記の4つです。

  • Slack
  • GAS
  • Googleスプレッドシート(APIトークン管理用)
  • AKASHI(勤怠ツール)のAPI

4つのツールを上の図のように連携させてSlackで打刻を実現します。

Slackの発言を受け取れるようにする

まずは、Slack上での発言をGASで受け取れるようにします。

これには、Outgoing Webhookを設定します。

Slackのアプリケーション追加ページより「Outgoing webhooks」を検索・選択します。

「Slackに追加」を押下します。

「Outgoing Webhook インテグレーションの追加」を押下します。

下記インテグレーションの設定を行います。

チャンネル:打刻宣言を受け取るチャンネルを設定します。

引き金となる言葉:打刻をする言葉を設定します。複数ある場合はカンマで区切ります。これらの単語で始まる行がある場合に反応します。

URL:下記の方法でGASのURLを取得して設定します。

GASのURLを取得

GASの実行URLを取得します。

GASのファイルを開き、右上の「デプロイ」「新しいデプロイ」を選択します。

歯車マークから「ウェブアプリ」を選択します。

「アクセスできるユーザー」を「全員」に変更し、「デプロイ」を選択します。

アクセスの承認を求められるので「アクセスを承認」を押下し、自分のアカウントでログインします。

ウェブアプリのURLをコピーし、先程のOutgoing Webhookの設定画面に貼り付けます。

以上でSlack上の発話を受け取る設定ができました。

APIトークンをスプレッドシートから取得する

AKASHIでの打刻には個々人で発行したAPIトークンが必要です。

参考:AKASHI 公開API 仕様 – AKASHI ヘルプセンター

そのため、SlackユーザーとそのユーザーのAKASHIのAPIトークンを結び付けなければいけません。そのためにGoogle スプレッドシートを使います。

上記のように名前とSlackユーザー名、AKASHIのトークンを記載したシートを用意します。

Slackユーザー名とAKASHIのトークンは各個人のアカウントが必要なので下記手順で取得してもらって記載してもらいましょう

Slackユーザー名の取得

自分のプロフィールを開き、三点リーダーから「アカウント設定」を開きます。

ユーザー名を開いてコピーします。このとき変更しないように注意しましょう。

AKASHIのAPIトークンの取得

AKASHIを開き、「APIトークン」を押下します。(もし「APIトークン」の選択肢がない場合、APIトークンの権限が設定されていない可能性があるため管理者に設定してもらいましょう。)

「APIトークン追加」をクリック

表示されたAPIトークンをコピーして「確定」を押下。(確定ボタン押下を忘れるとAPIトークンが使えません。)

以上でAPIトークンを取得できました。

続いて、スプレッドシートを読み込んでSlackとAKASHIのAPIトークンを結びつけます。

先程のスプレッドシートのURLをコピーします。

// Slackのユーザー名からAKASHIのAPIトークン特定 引数:Slackのユーザー名、戻り値:AKASHIのAPIトークン
function getAkashiAPIToken(slackName) {
    // スプレッドシートのURL
    const spreadSheetURL = "スプレッドシートのURL";
 
    //シート名
    const sheetName = "シート名";
 
    // スプレッドシートオブジェクト
    var spreadSheet = SpreadsheetApp.openByUrl(spreadSheetURL);
 
    // シートオブジェクト
    var sheet = spreadSheet.getSheetByName(sheetName);
 
 
 
    // key=Slackのユーザー名、value=AKASHIのAPIトークンのマップ
    var slackNameAkashiTokenMap = new Map();
 
    // スプレッドシートの情報をマップに追加
    for (i = 2; i <= sheet.getLastRow(); i++) {
        var slackUserName = sheet.getRange(i, 2).getValue();
        var akashiAPIToken = sheet.getRange(i, 3).getValue();
 
        slackNameAkashiTokenMap.set(slackUserName, akashiAPIToken);
    }
 
    // 発話ユーザーのトークンを特定
    var speechUserAkashiAPIToken = slackNameAkashiTokenMap.get(slackName);
 
    return speechUserAkashiAPIToken;
}

スプレッドシートのURL、シート名を記載して上記の関数を作成します。

これでSlackのユーザー名を引数にAKASHIのAPIトークンを取得する関数ができました。

Slackにメッセージを送れるようにする

次にSlackにメッセージを送る実装をしていきます。

Slackにメッセージを送るにはIncoming WebHooksを使います。

Slackのアプリケーション追加ページより「incoming webhooks」を検索・選択します。

「Slackに追加」を押下します。

投稿するチャンネルを選択し、「Incoming Webhook インテグレーションの追加」を押下します。

エンドポイントのURLが発行されます。これは後で使うのでコピーしておきましょう。(このURLを他人に知られると誰でもSlackに投稿できてしまうので他人に知られないようにしましょう。)

ちなみにですが、下にスクロールすると投稿するBotの名前やアイコンをカスタマイズすることができます。こちらも必要であればやっておきましょう。

次にGASの実装です。

// Slackにメッセージを送る 引数:メッセージ、スレッド投稿用タイムスタンプ
function sendToSlack(message, timestamp) {
    // Slackの投稿用URL
    const slackURL = "SlackのURL";
 
    if (timestamp != 0 || timestamp != null) {
        // Slack用のオプション
        var slackOption = {
            "text": message,
            "thread_ts": timestamp
        };
    } else {
        // Slack用のオプション
        var slackOption = {
            "text": message
        };
    }
    var payload = JSON.stringify(slackOption);
 
    // API叩くオプション
    var options = {
        "method": "POST",
        "contentType": "application/json",
        "payload": payload
    };
 
    UrlFetchApp.fetch(slackURL, options);
}

上記の関数を作成し、「slackURL」定数に先程取得したSlackのURLを設定します。

第一引数には送信するメッセージ、第二引数にはスレッド投稿用のタイムスタンプを設定する関数です。タイムスタンプはdopost関数の引数の「parameter.timestamp」で取得できます。

以上でSlackに送信する準備は完了です。

打刻する

ではいよいよAKASHIで打刻する関数を作成します。

ここは勤怠ツールによって異なるところなので、使用しているツールのAPIのページをご参照ください。AKASHIのAPIの仕様は下記を参照ください。

参考:AKASHI 公開API 仕様 – AKASHI ヘルプセンター

// 出勤 引数はAKASHIのAPIトークン、戻値は結果オブジェクト
function startWorking(akashiAPIToken) {
 
    // AKASHIのAPIのURL(打刻用)
    const stampAkashiAPIURL = "https://atnd.ak4.jp/api/cooperation/○○/"
 
    var apiURL = stampAkashiAPIURL + "stamps";
 
    // POSTする最終URLを生成 type=11で出勤
    apiURL = apiURL + "?token=" + akashiAPIToken + "&type=11";
 
    var option = {
        "method": "post"
    };
 
    // POST実行
    var result = UrlFetchApp.fetch(apiURL, option);
 
    return result;
}
 
// 退勤 引数はAKASHIのAPIトークン、戻値は結果オブジェクト
function finishWorking(akashiAPIToken) {
 
    // AKASHIのAPIのURL(打刻用)
    const stampAkashiAPIURL = "https://atnd.ak4.jp/api/cooperation/○○/"
   
    var apiURL = stampAkashiAPIURL + "stamps";
  
    // POSTする最終URLを生成 type=12で退勤
    apiURL = apiURL + "?token=" + akashiAPIToken + "&type=12";
 
    var option = {
        "method": "post"
    };
 
    // POST実行
    var result = UrlFetchApp.fetch(apiURL, option);
 
    // 結果をオブジェクト型に変換
    var obj = JSON.parse(result);
 
    return result;
}
 

startWorking関数で出勤、finishWorking関数で退勤ができます。それぞれの引数はAKASHIのAPIトークンです。

URLの○○の部分には会社ごとの固有のURLが入ります。

APIトークンの更新

注意点として、AKASHIのAPIトークンは有効期限が1ヶ月と1日なので、定期的な更新が必要です。今回は出勤の際に同時に下記の2つの関数で更新プロセスを入れます。

 
// AKASHIのAPIトークンを更新してトークン文字列を返す
function getNewAkashiToken(akashiAPIToken) {
 
    // AKASHIのAPIのURL(トークン更新用)
    const tokenAkashiAPIURL = "https://atnd.ak4.jp/api/cooperation/token/reissue/○○/"
 
    // APIのURL生成
    var apiURL = tokenAkashiAPIURL + "?token=" + akashiAPIToken;
 
    var option = {
        "method": "post"
    };
 
    // POST実行
    var result = UrlFetchApp.fetch(apiURL, option);
 
    // 結果をオブジェクト型に変換
    var obj = JSON.parse(result);
 
    // トークン取得
    var token = obj.response.token;
 
    return token;
 
}
 
// スプレッドシートのAKASHIトークンを新しいものに更新 引数:Slackのユーザー名、
function updateSpreadSheetAkashiToken(userName, newToken) {
 
    // スプレッドシートのURL
    const spreadSheetURL = "スプレッドシートのURL";
 
    //シート名
    const sheetName = "シート名";
 
    // スプレッドシートオブジェクト
    var spreadSheet = SpreadsheetApp.openByUrl(spreadSheetURL);
 
    // シートオブジェクト
    var sheet = spreadSheet.getSheetByName(sheetName);
    // ユーザー名が入力されている行番号
    var rowNum = 0;
    for (i = 2; i <= sheet.getLastRow(); i++) {
        if (sheet.getRange(i, 2).getValue() == userName) {
            rowNum = i;
            sheet.getRange(i, 3).setValue(newToken);
            break;
        }
    }
    if (rowNum == 0) {
        message = message + userNameMissingErrorMessage;
 
    }
}

getNewAkashiToken関数でAPIを叩いてトークンを更新、updateSpreadSheetAkashiToken関数でスプレッドシートの情報を更新します。

URLの○○の部分には会社ごとの固有のURLが入ります。

doPost関数の実装

これまで見てきたものを、GASのウェブアプリとして呼び出されるdoPost関数に実装します。

// outgoingで呼ばれる関数
function doPost(e) {
 
    // 文章エラーメッセージ
    const messageEroor = "文章エラーメッセージ\n";
 
    // 打刻エラーメッセージ
    const stampErrorMessage = "打刻エラーメッセージ";
 
    // アラートエラーメッセージ
    const alertErrorMessage = "アラートエラーメッセージ\n"
 
    // 更新エラーメッセージ
    const updateErrorMessage = "\n更新エラーメッセージ\n";
 
    // トークンが見つからないエラーメッセージ
    const tokenMissingErrorMessage = "トークン見つからないエラーメッセージ\n";
 
    // 出勤検知ワード
    const attendanceWords = ["はじめ", "はじめます", "始め", "おはよ", "出勤", "ハジメ"];
 
    // 退勤検知ワード
    const clockingOutWords = ["おつかれ", "お疲れ", "オツカレ", "退勤", "終わり", "おわり", "オワリ", "終わる", "おわる", "オワル"];
 
 
    // 投稿したユーザー名
    var userName = e.parameter.user_name;
 
    // 発話ユーザーのAPIトークンを指定
    var speechUserAkashiAPIToken = getAkashiAPIToken(userName);
 
    // nullチェック
    if (speechUserAkashiAPIToken == null || speechUserAkashiAPIToken == "") {
        message = userName + tokenMissingErrorMessage;
    } else {
        // 投稿されたメッセージ
        var postedMessage = e.parameter.text;
 
        var result = new Object();
        // 投稿された文章によって処理分岐
        if (isStartStringInArray(attendanceWords, postedMessage)) {
            try {
                // 出勤
                result = startWorking(speechUserAkashiAPIToken);
 
                message = userName + attendanceBotMessage;
 
            } catch (e) {
                // 打刻時のエラー
                message = message + stampErrorMessage + e.message;
            }
            try {
                // 出勤時のみAPIトークンを更新する
 
                // AKASHIの新APIトークン取得
                var newToken = getNewAkashiToken(speechUserAkashiAPIToken);
 
                // スプレッドシートを最新のトークンに更新
                updateSpreadSheetAkashiToken(userName, newToken);
 
            } catch (e) {
                // APIトークン更新時のエラー
                message = message + updateErrorMessage + e.message;
            }
 
        } else if (isStartStringInArray(clockingOutWords, postedMessage)) {
 
            try {
                // 退勤
                result = finishWorking(speechUserAkashiAPIToken);
 
                message = userName + clockingOutBotMessage;
 
 
            } catch (e) {
                // 打刻時のエラー
                message = message + stampErrorMessage + e.message;
            }
 
        } else {
            message = userName + messageEroor;
        }
 
    }
    sendToSlack(message, e.parameter.timestamp);
}
 
// 配列の文字が文字列の行頭にあるかチェック
function isStartStringInArray(array, str) {
    var result = false;
 
    for (let i = 0; i < array.length; i++) {
        if (str.indexOf(array[i]) === 0) {
            result = true;
            break;
        }
    }
    return result;
}

各メッセージを設定するのを忘れないようにしてください。

全体像は下記になります。

// outgoingで呼ばれる関数
function doPost(e) {
 
    // 文章エラーメッセージ
    const messageEroor = "文章エラーメッセージ\n";
 
    // 打刻エラーメッセージ
    const stampErrorMessage = "打刻エラーメッセージ";
 
    // アラートエラーメッセージ
    const alertErrorMessage = "アラートエラーメッセージ\n"
 
    // 更新エラーメッセージ
    const updateErrorMessage = "\n更新エラーメッセージ\n";
 
    // トークンが見つからないエラーメッセージ
    const tokenMissingErrorMessage = "トークン見つからないエラーメッセージ\n";
 
    // 出勤検知ワード
    const attendanceWords = ["はじめ", "はじめます", "始め", "おはよ", "出勤", "ハジメ"];
 
    // 退勤検知ワード
    const clockingOutWords = ["おつかれ", "お疲れ", "オツカレ", "退勤", "終わり", "おわり", "オワリ", "終わる", "おわる", "オワル"];
 
 
    // 投稿したユーザー名
    var userName = e.parameter.user_name;
 
    // 発話ユーザーのAPIトークンを指定
    var speechUserAkashiAPIToken = getAkashiAPIToken(userName);
 
    // nullチェック
    if (speechUserAkashiAPIToken == null || speechUserAkashiAPIToken == "") {
        message = userName + tokenMissingErrorMessage;
    } else {
        // 投稿されたメッセージ
        var postedMessage = e.parameter.text;
 
        var result = new Object();
        // 投稿された文章によって処理分岐
        if (isStartStringInArray(attendanceWords, postedMessage)) {
            try {
                // 出勤
                result = startWorking(speechUserAkashiAPIToken);
 
                message = userName + attendanceBotMessage;
 
            } catch (e) {
                // 打刻時のエラー
                message = message + stampErrorMessage + e.message;
            }
            try {
                // 出勤時のみAPIトークンを更新する
 
                // AKASHIの新APIトークン取得
                var newToken = getNewAkashiToken(speechUserAkashiAPIToken);
 
                // スプレッドシートを最新のトークンに更新
                updateSpreadSheetAkashiToken(userName, newToken);
 
            } catch (e) {
                // APIトークン更新時のエラー
                message = message + updateErrorMessage + e.message;
            }
 
        } else if (isStartStringInArray(clockingOutWords, postedMessage)) {
 
            try {
                // 退勤
                result = finishWorking(speechUserAkashiAPIToken);
 
                message = userName + clockingOutBotMessage;
 
 
            } catch (e) {
                // 打刻時のエラー
                message = message + stampErrorMessage + e.message;
            }
 
        } else {
            message = userName + messageEroor;
        }
 
    }
    sendToSlack(message, e.parameter.timestamp);
}
 
// 配列の文字が文字列の行頭にあるかチェック
function isStartStringInArray(array, str) {
    var result = false;
 
    for (let i = 0; i < array.length; i++) {
        if (str.indexOf(array[i]) === 0) {
            result = true;
            break;
        }
    }
    return result;
}
 
// 出勤 引数はAKASHIのAPIトークン、戻値は結果オブジェクト
function startWorking(akashiAPIToken) {
 
    // AKASHIのAPIのURL(打刻用)
    const stampAkashiAPIURL = "https://atnd.ak4.jp/api/cooperation/○○/"
 
    var apiURL = stampAkashiAPIURL + "stamps";
 
    // POSTする最終URLを生成 type=11で出勤
    apiURL = apiURL + "?token=" + akashiAPIToken + "&type=11";
 
    var option = {
        "method": "post"
    };
 
    // POST実行
    var result = UrlFetchApp.fetch(apiURL, option);
 
    return result;
}
 
// 退勤 引数はAKASHIのAPIトークン、戻値は結果オブジェクト
function finishWorking(akashiAPIToken) {
 
    // AKASHIのAPIのURL(打刻用)
    const stampAkashiAPIURL = "https://atnd.ak4.jp/api/cooperation/○○/"
 
    var apiURL = stampAkashiAPIURL + "stamps";
  
    // POSTする最終URLを生成 type=12で退勤
    apiURL = apiURL + "?token=" + akashiAPIToken + "&type=12";
 
    var option = {
        "method": "post"
    };
 
    // POST実行
    var result = UrlFetchApp.fetch(apiURL, option);
 
    // 結果をオブジェクト型に変換
    var obj = JSON.parse(result);
 
    return result;
}
 
// AKASHIのAPIトークンを更新してトークン文字列を返す
function getNewAkashiToken(akashiAPIToken) {
 
    // AKASHIのAPIのURL(トークン更新用)
    const tokenAkashiAPIURL = "https://atnd.ak4.jp/api/cooperation/token/reissue/○○/"
 
    // APIのURL生成
    var apiURL = tokenAkashiAPIURL + "?token=" + akashiAPIToken;
 
    var option = {
        "method": "post"
    };
 
    // POST実行
    var result = UrlFetchApp.fetch(apiURL, option);
 
    // 結果をオブジェクト型に変換
    var obj = JSON.parse(result);
 
    // トークン取得
    var token = obj.response.token;
 
    return token;
 
}
 
// スプレッドシートのAKASHIトークンを新しいものに更新 引数:Slackのユーザー名、
function updateSpreadSheetAkashiToken(userName, newToken) {
    // ユーザー名が入力されている行番号
    var rowNum = 0;
    for (i = 2; i <= sheet.getLastRow(); i++) {
        if (sheet.getRange(i, 2).getValue() == userName) {
            rowNum = i;
            sheet.getRange(i, 3).setValue(newToken);
            break;
        }
    }
    if (rowNum == 0) {
        message = message + userNameMissingErrorMessage;
 
    }
}
 
// Slackのユーザー名からAKASHIのAPIトークン特定 引数:Slackのユーザー名、戻り値:AKASHIのAPIトークン
function getAkashiAPIToken(slackName) {
    // スプレッドシートのURL
    const spreadSheetURL = "スプレッドシートのURL";
 
    //シート名
    const sheetName = "シート名";
 
    // スプレッドシートオブジェクト
    var spreadSheet = SpreadsheetApp.openByUrl(spreadSheetURL);
 
    // シートオブジェクト
    var sheet = spreadSheet.getSheetByName(sheetName);
 
 
 
    // key=Slackのユーザー名、value=AKASHIのAPIトークンのマップ
    var slackNameAkashiTokenMap = new Map();
 
    // スプレッドシートの情報をマップに追加
    for (i = 2; i <= sheet.getLastRow(); i++) {
        var slackUserName = sheet.getRange(i, 2).getValue();
        var akashiAPIToken = sheet.getRange(i, 3).getValue();
 
        slackNameAkashiTokenMap.set(slackUserName, akashiAPIToken);
    }
 
    // 発話ユーザーのトークンを特定
    var speechUserAkashiAPIToken = slackNameAkashiTokenMap.get(slackName);
 
    return speechUserAkashiAPIToken;
}
 
// Slackにメッセージを送る 引数:メッセージ、スレッド投稿用タイムスタンプ
function sendToSlack(message, timestamp) {
    // Slackの投稿用URL
    const slackURL = "SlackのURL";
 
    if (timestamp != 0 || timestamp != null) {
        // Slack用のオプション
        var slackOption = {
            "text": message,
            "thread_ts": timestamp
        };
    } else {
        // Slack用のオプション
        var slackOption = {
            "text": message
        };
    }
    var payload = JSON.stringify(slackOption);
 
    // API叩くオプション
    var options = {
        "method": "POST",
        "contentType": "application/json",
        "payload": payload
    };
 
    UrlFetchApp.fetch(slackURL, options);
}

以上で実装は完了です!

人を責めるな、しくみを責めろ

「人を責めるな、しくみを責めろ」という言葉は、トヨタ自動車での言葉(※)ですが、「打刻漏れが多い」という事実に対して、「呼びかけを増やす」といった「人を責める」という解決法ではなく、「毎日開いているツールから打刻できるようにする」という「しくみを責める」方法で解決することができたことは大きな収穫だったと思います。

※参考:トヨタ流「成果が出る」最強の習慣トップ3 | リーダーシップ・教養・資格・スキル | 東洋経済オンライン | 社会をよくする経済ニュース

また、退勤時の挨拶に一言添える人が増え、社内のコミュニケーションが促進されたという副次的な効果もありました。

もし勤怠の打刻漏れで社内で悩んでいる声を聞いたら、どのようにしくみを変えると人を動かすことができるのか、今回の例も参考に考えてみてくださいね。

関連記事

【月のまとめ】2022年10月公開の記事ランキング
【月のまとめ】2022年10月公開の記事ランキング
続きを見る
2022年、アナグラムのブログでよく読まれた記事TOP20
2022年、アナグラムのブログでよく読まれた記事TOP20
続きを見る