上司に怒られない勤怠時間の自動入力方法

この記事はTech KAYAC Advent Calendar 2019の21日目の記事です。

経緯

こんにちは、今サーバサイドで仕事をしてるUnityエンジニアのです。

入社した以来、いつもノートで出退社時間を書いて、金曜日か月曜日で勤怠管理システムでまとめて入力することをやっていました。 しかし年を取ってきて、仕事が忙しくなるなどの原因で、よく勤怠時間をメモし忘れています。毎日勤怠をつける習慣があれば問題ないけど、やはり忘れてしまうことがよくありました。その時、KPTシートで「出社退社時間を記録する仕組みを作る」というトライを書きました。

ネットで検索してみたら、自動で打刻してくれる勤怠システムがすでにたくさんあります。しかし、それらの対象は会社管理者で、会社がそのシステムを使わない限り、社員としては何も恩惠を受けません。

だからこの記事では、管理者じゃなく労働者の立場から、自己申告の勤怠システムに対する自動入力の方法を考えました。 実際に動いたものはこんな感じです。

f:id:rockwjx:20191219170333g:plain
出退社時間の記録から、勤怠入力ためのデータ生成
f:id:rockwjx:20191219200326g:plain
データに応じる自動入力

具体的なやり方

今回の課題は2つに分けます。

  • 上司に怒られない:言い换えれば正しく勤怠時間を入力すること
  • 時間の自動入力

それぞれを満たすために、出退社時間の集計勤怠の自動入力2つの仕組みが必要だと考えました。

出退社時間の記録

何もやらずに、自動で時間を記録してくれるものを探した結果、IFTTTが出てきました。自分はAndroid端末を持っているので、Wifiの接続状況を記録してくれるAppletを使用しました*1

時間が経つにつれて、たくさんのデータが記録されました。生データはこんな感じです。

f:id:rockwjx:20191219131849p:plain
IFTTTからもらった生データ

これで、最初のconnected toの時間と最後のdisconnectd fromの時間を見ると、一応一日の出社、退社時間が分かるでしょう*2

記録の整形&集計

上に書いたとおり、「最初のconnected toの時間と最後のdisconnectd fromの時間」を見ればいいですけど、自動化とは言えません。集計の作業はもちろんコードにやってもらいます。

IFTTTからもらった日付は「June 11, 2019 at 01:21PM」みたい、Spreadsheetで認識できないフォーマットです。しかも、月は英語で、atを除いても簡単に日本アカウントのSpreadsheetに扱えないです。

幸いにも少し整形すれば、Google Apps ScriptがDataオブジェクトとして扱ってくれます。

// IFTTTからの「July 18, 2019 at 10:02AM」みたいな日付の文字列は認識できないので、
// 「July 18, 2019 10:02 AM」のように変えたら、Dateに変換できる
function formatToDate(dateStr) {
  dateStr = dateStr.replace("at ", "").replace("AM", " AM").replace("PM", " PM");
  var d = new Date(dateStr);
  return d;
}

日付フォーマットの問題を解決できたら、毎日の出退社時間は簡単に集計できます。

function getTodayWorkTime() {
  var now = new Date();
  getWorkTime(now);
}

function getWorkTime(date) {
  var data = logSheet.getRange(1, 1, logSheet.getLastRow(), 3).getValues();
  
  var todayData = data.filter(function(rowData) {
    return rowData.length > 1 && rowData[1] === OFFICE_WIFI_NAME;
  })
  .map(function(rowData) {
    var isConnect = rowData[0] === "connected to" ? true : false;
    var date = formatToDate(rowData[2]);
    return {isConnect: isConnect, date: date};
  })
  .filter(function(resultData){
    // 指定した日付だけのデータを残す
    var todayStr = Utilities.formatDate(date, "Asia/Tokyo", "yyyy-MM-dd");
    var dateStr = Utilities.formatDate(resultData.date, "Asia/Tokyo", "yyyy-MM-dd");
    return dateStr === todayStr;
  });

  if (todayData.length <= 0) return;
  
  var clockIn;
  var clockOut;
  var i;
  // 毎日最初の接続時間が出社時間となる
  for (i = 0; i < todayData.length; i++) {
    if (todayData[i].isConnect) {
      clockIn = todayData[i].date;
      break;
    }
  }
  // 毎日最後の切断時間が退社時間となる
  for (i = todayData.length - 1; i >= 0; i--) {
    if (!todayData[i].isConnect) {
      clockOut = todayData[i].date;
      break;
    }
  }
  
  AddWorkTimeLog(date, clockIn, clockOut);
}

そして、AddWorkTimeLog関数で整形したデータをシートに記入する処理を書いて、このように出退社時間のまとめシートができました

f:id:rockwjx:20191219144155p:plain
出社退社時間のまとめ
これで、毎日実行するトリガーを設定すると、毎日の労働時間がここに集計できるでしょう。

自動入力のためのデータ再整形

次は勤怠時間の自動入力の部分です。

KayacはZacという勤怠管理システムを使っています。

f:id:rockwjx:20191220002621p:plain
Zac

画像のように、時間の入力はテキストではなく選択肢です。毎日勤怠を入力するために、8つくらい*3の操作をしなければならないです。自動入力のために、「時間のデータ」=>「選択肢のインデックス」に変換するのが必要となります。

function getTimeIndex(hour, minute) {
  // 時間の選択肢は「5」からなので
  var hourIndex = hour ? hour - 4 : 0;
  var minuteIndex = 0;
  // 分のインデックスは[0,15,30,45]しかないので
  if (minute){
    if (minute < 8) minuteIndex = 0;
    else if (minute < 23) minuteIndex = 1;
    else if (minute < 38) minuteIndex = 2;
    else if (minute < 53) minuteIndex = 3;
    else {
      // 9時58分などの時間を10時00分にする
      minuteIndex = 0;
      hourIndex++;
    }
  }
  return {hourIndex: hourIndex, minuteIndex: minuteIndex};
}

そして、作業時間(実労働時間)のインデックスも「退社時間インデックス - 出社時間インデックス」で計算できます

function getRequireTime(inHourIndex, inMinuteIndex, outHourIndex, outMinuteIndex) {
  var requireHourIndex = outHourIndex - inHourIndex - 1; // 休憩時間は1時間と想定
  var requireMinuteIndex = outMinuteIndex - inMinuteIndex;
  // 引き算みたいに、分のインデックスがマイナスになったら、時間のインデックスから1を引く
  if (requireMinuteIndex < 0) {
    requireHourIndex -= 1;
    requireMinuteIndex += 4;
  }
  return {hourIndex: requireHourIndex, minuteIndex: requireMinuteIndex};
}

休憩時間は1時間に固定するので、これで休憩時間以外の6箇所のインデクスが全部計算されました。*4

f:id:rockwjx:20191219174757g:plain
勤怠システム入力用のデータ生成

今↑のデータをシートに記入するAddWorkTimeLog関数の中身を見てみましょう。

先程書いたインデックスの計算関数を使って、もらったデータをシートに記載するだけです。

function AddWorkTimeLog(date, clockIn, clockOut) {
  var sheetName = Utilities.formatDate(date, "Asia/Tokyo", "yyyy/MM");
  var resultSheet = spreadsheet.getSheetByName(sheetName);
  // 月のシートがない場合は作る
  if (!resultSheet) {
    resultSheet = spreadsheet.insertSheet(sheetName, 1);
    // header
    resultSheet.getRange(1, 1, 1, 10).setValues([[
      "date",
      "clock-in hour index",
      "clock-in minute index",
      "clock-out hour index",
      "clock-out minute index",
      "require hour index",
      "require minute index",
      "clock-in time",
      "clock-out time",
      "script"
    ]]);
  }
  
  var date = Utilities.formatDate(date, "Asia/Tokyo", "yyyy/MM/dd");
  var inHour = clockIn ? clockIn.getHours() : "";
  var inMinute = clockIn ? clockIn.getMinutes() : "";
  var outHour = clockOut ? clockOut.getHours() : "";
  var outMinute = clockOut ? clockOut.getMinutes() : "";
  
  var inIndexes = getTimeIndex(inHour, inMinute);
  var outIndexes = getTimeIndex(outHour, outMinute);
  var requireIndexes = getRequireTime(inIndexes.hourIndex, inIndexes.minuteIndex, outIndexes.hourIndex, outIndexes.minuteIndex);
  
  var inTime = Utilities.formatDate(clockIn, "Asia/Tokyo", "HH:mm");
  var outTime = Utilities.formatDate(clockOut, "Asia/Tokyo", "HH:mm");
  
  resultSheet.getRange(resultSheet.getLastRow() + 1, 1, 1, 9)
    .setValues([[date, inIndexes.hourIndex, inIndexes.minuteIndex, outIndexes.hourIndex, outIndexes.minuteIndex,
                 requireIndexes.hourIndex, requireIndexes.minuteIndex, inTime, outTime]]);
}

ここまでは、勤怠入力に必要なデータが全部揃いました。

自動入力

チームの中山さん(先日の「カレーライスを綺麗に食べる方法」記事を書いた方)のコードを参考にして、1つずつ時間を入力するのかわりに、ブラウザのConsoleで一発ですべての時間を入力するようなコードを生成しました。*5

="document.querySelector('select[name=time_in_hour]').selectedIndex = "&B2&";"&CHAR(10)&
"document.querySelector('select[name=time_in_minute]').selectedIndex = "&C2&";"&CHAR(10)&
"document.querySelector('select[name=time_out_hour]').selectedIndex = "&D2&";"&CHAR(10)&
"document.querySelector('select[name=time_out_minute]').selectedIndex = "&E2&";"&CHAR(10)&
"document.querySelector('select[name=time_break_input_hour]').selectedIndex = 1;"&CHAR(10)&
"document.querySelector('select[name=time_break_input_minute]').selectedIndex = 0;"&CHAR(10)&
"document.querySelector('select[name=time_required_hour1]').selectedIndex = "&F2&";"&CHAR(10)&
"document.querySelector('select[name=time_required_minute1]').selectedIndex = "&G2&";"&CHAR(10)&
"SubmitSetNippou(1, '"&TEXT(A2,"yyyy/MM/dd")&"');"

生成したJavaScriptのコードをブラウザのコンソールで実行してみると、ちゃんと時間を設定してくれました。 f:id:rockwjx:20191219200527g:plain

今後の課題

今回解決したのは出退社時間の記録だけ、一日複数の案件を関わる場合、各プロジェクトで使う時間が集計できないです。

Zacでは一日の勤怠を確定すると、ページが更新されるので、ブラウザのコンソールを使う場合、一日ずつしか入力できないという限界があります。複数の日をまとめて入力するのは、拡張機能やTampermonkeyで作れるかもしれないけど、今回はできてないです。

最後に

確か大学の時かな、どこから「毎日手間がかかることは自動化すべき」みたいな話を言われて、その話がずっと頭の中で残っています。日本でも「怠慢はエンジニアの美徳だ」をよく聞いています。

今回紹介した方法は複雑なロジックがないけど、エンジニアという職種に限らず、繰り返す部分を自動化にしましょうという意識を伝えることができたら嬉しいと思います。

*1:このappletはすべてのWifiの接続状況を記録してるけど、実は特定のwifiだけ記録するappletがあります。さらに、Locationというサービスもあります。会議室に行く時wifiが接続したり、切断したりするので、余計な記録がたくさんあるけど、Locationを使ったほうが、特定の地理範囲内では記録が1つしかなくて、より集計しやすいです。

f:id:rockwjx:20191220003820p:plain
こういうふうに範囲を設定できる

*2:Appletがちゃんと動いているか監視するために、slackに送信するappletを作りました。

f:id:rockwjx:20191219133009p:plain
Wifiの記録をslackへ送信

*3:1つのプロジェクトのみ所属という前提で

*4:GIFを取るために1ヶ月丸ごとのデータを生成したけど、実は毎日データを生成しています

*5:AddWorkTimeLog関数で、直接「script」列をSetValueしても良い