IFTTT、GASを使ってfreeeのタイムレコーダーでwi-fi打刻する


背景、ゴール

 freeeAPIの仕様説明なども兼ねて記載しますので、とにかく動けば良い方は説明をすっ飛ばしてサンプルスクリプト、IFTTTの設定のみご参照ください。
また、今回はコードを最小限にしたためアクセストークンは自力で取得する必要があります。

背景

  • 働き方改革の流れの中で労務管理、特に勤怠の重要性が高まっており、人事労務freeeではより正確な勤怠記録のためのタイムレコーダー機能に対応している。
  • freee社でもタイムレコーダー機能を利用した勤怠管理が6月から始まったが、どうしても打刻を忘れずにつけることができないので、IFTTTとGAS、freeeAPIを用いて自動化することにした。

やること

  • 社用携帯(Android))が社内wi-fiに繋がったら打刻、切れたら退勤を打刻する。
    • IFTTTにてAndroid Deviceのwi-fi情報をトリガーにwebhooksからGASを実行する。
    • GASから人事労務freeeAPIを用いて社内wi-fiに繋がったら出勤、接続が切れたら退勤を打刻する。
    • 打刻のための日付変更時間はAM5時とする。

やらないこと

  • エラーハンドリング(とりあえず、正常系が動くことがゴール)
  • iPhoneへの対応
    • iPhoneのwi-fiのON/OFFをトリガーにwebhookリクエストできれば同じGASが使えるので、興味ある人はCodyl Connectを用いて試してみてください。
    • [追記]Codyl ConnectでのiPhone wi-fi打刻も試してみて一応成功することが確認できました。ただし、50回に1回ぐらいしかCodyl Connectが動作しないのでおすすめしません。

freeeの準備

  1. 前提として人事労務freeeにてタイムレコーダー機能がONになっている必要があります。
  2. 次章以降ではアプリ作成、アクセストークン、company_id/employee_idの取得が完了した状態からスタートします。必要に応じて以下を参照ください

タイムレコーダーAPIの使い方

  1. /users/meを用いて、company_idと、employee_idを取得します。
    • 今回のGASでは2項目は事前に取得しているものとして、スクリプトプロパティに直接保存しています。
  2. /time_clocks/available_typesを用いて打刻可能な種別を取得します。
    • それぞれのステータスで以下の種別が可能です。
      • 出勤前:clock_in
      • 出勤後かつ退勤前:
        • break_begin
        • clock_out
      • 退勤後:clock_out
        • 退勤時間はアップデートが可能です。詳細は後述します。
  3. /time_clocksを用いてタイムカードを打刻します。

タイムレコーダーAPIの仕様について補足

  • 打刻種別について
    • タイムレコーダーでは退勤時間をアップデートすることが可能なため、退勤後でもclock_outを返します。
    • 0時を過ぎると翌日となり、退勤済みの場合はclock_inが選択可能になるため、0時以降退勤のケースを考慮する場合はdateを指定して利用します。
      • 後述するスクリプトでは5時を日付変更時間としてしています。
      • dateの形式はYYYY-MM-DDで指定します。
  • タイムレコーダーPOSTについて
    • POST時のbase_dateは YYYY-MM-DD で指定します。

GASの実装

事前準備

  • freeeAPIを利用するための項目をユーザープロパティまたはスクリプトプロパティに保存します。
    1. スクリプトエディアを開く
    2. 3つのスクリプトファイルを作成する。
      
      ┣ main.gs   // IFTTTからのプッシュで動作するスクリプト
      ┣ auth.gs  // リフレッシュトークンを用いたトークン取得を行うスクリプト
      ┣ freee.gs // 人事労務freeeのAPIリクエストに関するスクリプト
      
    3. auth.gsの1番下にあるcreatePropertyファンクションの第二引数に次の値を入れ、ファンクションを実行します。実行後、値をスクリプトから値を削除します。
      • client_id
      • client_secret
      • access_token
      • refresh_token
      • company_id
      • emp_id
    4. Moment.jsのライブラリを導入する

アクセストークンの更新

auth.gs
/******************************************************************
function name |refreshAccessToken
summary       |リフレッシュトークンを用いてアクセストークンを更新
******************************************************************/
function refreshAccessToken() {
  //トリガーを削除
  deleteTrigger();
  //変数を取得
  var refresh_token = PropertiesService.getUserProperties().getProperty('refresh_token');
  var client_id = PropertiesService.getScriptProperties().getProperty('client_id');
  var client_secret = PropertiesService.getScriptProperties().getProperty('client_secret');
  //リクエスト先URL
  var url_token = "https://accounts.secure.freee.co.jp/public_api/token";
  //リクエストボディ
  var request_body = "grant_type=refresh_token&client_id=" + client_id + "&client_secret=" + client_secret + "&refresh_token=" + refresh_token;
  //POSTオプション
  var options = {"method":"POST","payload" : request_body};    
  //POSTリクエストを送信
  var response = UrlFetchApp.fetch(url_token,options).getContentText();    
  //レスポンスからトークンをスクリプトプロパティにに保存
  var parsed = JSON.parse(response);
  var access_token = parsed.access_token
  PropertiesService.getUserProperties().setProperty('access_token',access_token);
  PropertiesService.getUserProperties().setProperty('refresh_token',parsed.refresh_token);
  //トリガーを翌日3時にセット
  setTrigger();
  //アクセストークンを返す
  return access_token;
}

/******************************************************************
function name |setTrigger
summary       |トリガーをセットする。最初の1回のみ実行。
******************************************************************/
// 翌日の03時00分にトリガーを設定
function setTrigger() {
  var day = new Date();
  day.setDate(day.getDate()+1);
  day.setHours(3);
  day.setMinutes(00);
  ScriptApp.newTrigger("refreshAccessToken").timeBased().at(day).create();
}

/******************************************************************
function name |deleteTrigger
summary       |トリガーを削除する
******************************************************************/
// その日のトリガーを削除する
function deleteTrigger() {
  var triggers = ScriptApp.getProjectTriggers();
  for(var i=0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() == "refreshAccessToken") {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
}

/******************************************************************
function name |createProperty
summary       |初期構築用
******************************************************************/
function createProperty(){
  PropertiesService.getScriptProperties().setProperty('client_id','');
  PropertiesService.getScriptProperties().setProperty('client_secret','');
  PropertiesService.getUserProperties().setProperty('refresh_token','');
  PropertiesService.getUserProperties().setProperty('access_token','');
  PropertiesService.getUserProperties().setProperty('emp_id','');
  PropertiesService.getUserProperties().setProperty('company_id','');
}

freee APIのリクエストfunctions

freee.gs
/******************************************************************
function name |getTypes
summary       |打刻可能種別を取得する
request_url   |https://api.freee.co.jp/hr/api/v1/employees/{emp_id}/time_clocks/available_types
method        |GET
******************************************************************/
function getTypes(date) {
  var requestUrl = "https://api.freee.co.jp/hr/api/v1/employees/" + EMP_ID + "/time_clocks/available_types?company_id=" + COMPANY_ID + "&date=" + date;
  var headers = {"Authorization" : "Bearer " + A_TOKEN};
  var options = {"method"  : "GET","headers" : headers};
  var res = UrlFetchApp.fetch(requestUrl,options).getContentText();
  var res = JSON.parse(res);
  Logger.log(res.available_types[0])
  return res.available_types[0];
}

/******************************************************************
function name |postTimeRecord
summary       |doPostから出退勤を打刻するファンクション
request_url   |https://api.freee.co.jp/hr/api/v1/employees/{emp_id}/time_clocks
method        |PUT
******************************************************************/
function postTimeRecord(type,date) {
  var requestUrl = "https://api.freee.co.jp/hr/api/v1/employees/" + EMP_ID + "/time_clocks";
  var headers = {"Authorization" : "Bearer " + A_TOKEN};
  var requestBody = makeRequestBody(type,date)
  var options = {
      "method":"POST",
      "accept" : "application/json",
      "contentType" : "application/json",
      "headers":headers,
      "payload":JSON.stringify(requestBody),
      muteHttpExceptions: true
    };
  var res = UrlFetchApp.fetch( requestUrl , options ).getContentText();
  var res = JSON.parse(res);
  return res;
}

/******************************************************************
function name |makeRequestbody
summary       |リクエストボディの作成
******************************************************************/
function makeRequestBody(type,date){
  var requestBody = 
      {
        "company_id": COMPANY_ID,
        "type": type,
        "base_date": date
      };
  return requestBody;
}

doPost関数を作成し外部からGASを実行する準備をする

後にこのプロジェクトをウェブアプリケーションにするのですが、ウェブアプリケーションのデバッグが難しいので、実行結果をメール送信する。
エラー発生時のみ通知したい場合はcatch文の中にメール送信メソッドを入れてください。

main.gs
//情報を取得
A_TOKEN = PropertiesService.getUserProperties().getProperty('access_token');
EMP_ID = PropertiesService.getUserProperties().getProperty('emp_id');
COMPANY_ID = PropertiesService.getUserProperties().getProperty('company_id');

/*****************************************************************
function name |doPost
summary       |IFTTTからのPOSTを受け取って動作するScript
******************************************************************
参照ライブラリ
title        |Moment
project_key  |MHMchiX6c1bwSqGM1PZiW_PxhMjh3Sh48
******************************************************************/
function doPost(e) {
  try{
    //準備
    var today = Moment.moment().format('YYYY-MM-DD');
    var five_clock = Moment.moment(today).add('hour',5).format();
    var now = Moment.moment().format();
    var PostData = JSON.parse(e.postData.contents); //「connected to」 OR 「disconnected from」
    var occured_type = PostData.occuredType;

    // 5時までは前日と見做す
    if (now >= five_clock){
      var date = today;
    }else{
      var date = Moment.moment(today).subtract('day',1).format('YYYY-MM-DD'); 
    }

    //打刻可能種別を取得
    var type = getTypes(access_token,date);

    //適切な打刻を判別してPostする
    if(occured_type == 'disconnected from'){
      var res = postTimeRecord('clock_out',date);
    }else if(occured_type == 'connected to' && type == 'clock_in'){
      var res = postTimeRecord(type,date);
    }else{
      var res = "出勤打刻済みのため、リクエストを行ないませんでした";
    }
    //実行内容をメールの本文にする
    var body = "occured_type = " + occured_type + "\n"
                + "res=" + res + "\n"
                + "e=" + JSON.stringify(e) + "\n"
  } catch (c) {
    //エラー内容をメール本文にする
    var body = c.message;
  }
  MailApp.sendEmail('[email protected]', 'スクリプト実行結果', body);
  return;
}

ウェブアプリケーションとしてGASを実行できるようにする

  1. 公開 > ウェブアプリケーションとして導入
    • プロジェクトバージョン:new
    • 次のユーザーとしてアプリケーションを実行:自分
    • アプリケーションにアクセスできるユーザー:全員
  2. 表示されたURLは次章で使うので、そのままにしておいてください。
    • 消してしまった場合は(1)の手順で確認できます。

IFTTTの設定

IFTTTの基本的な使い方は既に記事が充実しているので、そちらをご参照ください。
- 参考:IFTTTとは?異なるプラットフォームを連携する便利ツールの使い方とアプレット9選

Andoid Deviceからwi-fiをトリガーに設定する

  1. New Applet > if+ > Choose a serviceで「Android Device」を選択
  2. 「Connects or disconnects from a specific WiFi network」を選択
  3. トリガーにしたいNewwork name(=SSID)を入力

「Connects or disconnects from a specific WiFi network」はwi-fi切断時の動作が不安定になっているようです。うまく動かない場合は「Connects or disconnects from a any WiFi network」または、Integromatなどの別サービスの利用をご検討ください。

webhooksからGASを実行する

  1. +that > Choose a serviceで「webhooks」を選択

  2. 「Make a web request」を選択

  3. 各種設定値を入力

    • URL:前章で取得したGASのURL
    • Method:POST
    • Content Type:application/json
    • Body:{"occuredType":"{{ConnectedToOrDisconnectedFrom}}"}
  4. 携帯のwi-fiをON/OFFして動作確認を行ないます。

  5. 人事労務freeeで打刻ができていれば、完了です。

最後に

今回はIFTTTとGASを用いて打刻を自動化することで勤怠入力漏れを防ぐ方法を紹介しました。
明日からもう「あ、打刻してない」という口癖とは無縁な打刻レスライフをお楽しみ下さい。

管理部の方は同様の仕組みでPCのログから打刻すれば監査時に指摘される「PCログと出退勤打刻に乖離がある」という指摘を躱すことができるので、ぜひご検討下さい。