Slack×GASで作った勤怠Botの話#5

UG Advent Calendar 2020 の5日目の記事です。

どうもbarusuです。知らない方は初めまして。
昨日の続きです。
フィクションと思って読んでいただければ幸いです。

Slack×GASで作った勤怠Botの話 #5

2020年5月。
良くも悪くもコロナ禍による新しい生活様式が定着しはじめた頃のお話。

―――――――ちょっとさ、勤怠システム作れない?

すべては法務部長の一言から始まった…

全10話分の5話目となります。
 2020/12/01:導入,初期の要望
 2020/12/02:設計
 2020/12/03:構築その1 :SlackBotを作る
 2020/12/04:構築その2 :GASを書く(基本メソッド編)
 2020/12/05:構築その3 :GASを書く(ユーティリティ編) ← 今日はココ
 2020/12/06:構築その4 :GASを書く(機能実装編:打刻処理)
 2020/12/07:構築その5 :GASを書く(機能実装編:ユーザー管理機能,リマインド機能) 
 2020/12/08:構築その6 :色々書く(機能実装編:,勤怠修正機能)
 2020/12/09:単体,ユーザーテスト
 2020/12/10:そして伝説へ...

Barusu
「ひひーん」

Barusu
「前回は下準備と基礎メソッドを追加したところまでだったな」

▼簡易設計
下準備:SlackAccessTokenを設定しておく
基礎
 投稿を受け取り、スプレッドシートに記載してSlackに返す
 投稿したユーザー名をSlackAPIを用いて取得
 ユーザーにDMを送る
 投稿内容に応じて異なる処理を実行する
---(今日はココから)---
各種ユーティリティ
 日付のフォーマットを変更する
 スプレッドシートの範囲から連想配列を作成する
 連想配列を二次元配列に変換する
 祝日一覧を取得してシートに転記
 GoogleWorkspaceのユーザー一覧を取得してシートに転記 
---(今日はココまで)---
機能実装予定のもの
1. 打刻機能
 a.  出勤、退勤、中抜け に反応する仕組みを作る
 b. 出勤打刻を受け取ってスプレッドシートに今の時刻を記載する
  例外:出勤済ならば「出勤してます」と返す
 c. 中抜けを受け取ってスプレッドシートに記載する
  例外:未出勤ならば「出勤してません」と返す
  例外:時刻データが不正の場合は「形式が正しくありません」と返す
 d. 退勤打刻を受け取ってスプレッドシートに記載する
  例外:未出勤ならば「出勤してません」と返す
  例外:退勤済ならば「退勤済ですが上書きしますか?」と返す
2.ユーザー管理機能
 a. 初めて投稿したユーザーは勤怠ユーザーリストに追加する
 b. Gsuiteからメールアドレスを取得し、Slackユーザーリストと紐付ける
3. リマインド機能
 a. 今日が平日ならば→指定時間帯に未打刻のユーザーにDMを送る
 b. 退勤をしていないユーザーにDMを送る
4. 勤怠修正機能
 a. Googleフォームで修正申請を受付
 b. 指定の承認者のみが承認できるようにする
 b. 別の勤怠修正フォームから承認された勤怠データとデータを合流させる
5. アラート機能
 a. 月末残業予測値を算出し、しきい値を超えた者はアラートを出す 

Barusu
「やっぱり多くね…………??」

Barusu
「とりあえず今日はユーティリティ関数を追加する」

Barusu
「っと、その前に解説入れとこか」

(解説)ユーティリティ関数ってなんや

Barusu
「GASはJavascriptをベースにしてるので、function 名前 (引数){記述}としたひとまとまりを関数とし、別の関数内から呼び出せる。
身近な例で挙げると、笑点の山田くん。
「山田くーん、XXさんに座布団N枚 (持ってきて or 持っていって)」
命令の例としては「小遊三さんの座布団1枚持っていって」とか「泰平さんに座布団1枚持ってきて」とか。
これはれっきとした座布団関数と呼べる。
そう、こんな感じの便利なやつを追加するってわけよ」

▼ 座布団関数記述例
function Yamadakun (target,zabuton,operetion){
~座布団処理~
}

function Shikai(){

Yamadakun (Koyuza,1,"Decrease"); //小遊三さんの座布団1枚持っていって
Yamadakun (Taihei,1,"Increase"); //泰平さんに座布団1枚持ってきて
}​

(解説)共通化すべきものとそうでないものについて

Barusu
「なんでもかんでもユーティリティ関数にしたらいいのか?というとそうではない。
ひよっこエンジニアに毛が生えた程度の理解だけど、自分なりの見解を述べておく。
プログラムには、似たようなロジックを何度も書くことがあり、これとこれって同じ動作だから関数にしてまとめた方が良いんでない?と思い至るものも多々ある。
複数箇所に記載しているコードを一つの部品にしてしまい、各所から呼び出すことで記載を省略でき、修正箇所をへらすことができるのがメリット。
昔はそれでガンガン共通化していたらしいんだけど、最近ではアンチパターンになりつつあるっぽい。」
※参考リンク置いときます

Barusu
「まぁ詳しいことは俺もわからんけど自分なりの考えとしては、In/Outが同じ条件になるものは共通化して構わんのではと考える。
もしかしたら俺が言ってるのは小さな部品化の話で、共通化の概念が捉えている事象はもっと大きな範囲なのかもしれないけどw
なので、今回追加するユーティリティ関数は利用シーンが変わってもI/Oのデータ定義が変わらないだろうというものに限る」

各種ユーティリティ関数を追加する

Barusu
「解説はここらでおいといて、勤怠Botをコーディングするときに使うであろうユーティリティを追加していく」

日付のフォーマットを変更する

Barusu
「Javascriptのdate型は闇が深いことで有名」

▼ここがツライよJavascript
・new Date や Date.parse で時刻を含まない日付文字列をパースすると、UTC 00:00:00 になる。
・Date.parse が返すのは Date でなく整数
・他色々

Barusu
「わかりやすい例として。
こういう↓GASを実行しても同じ値にはならず、記述によって値が異なるとかいう謎仕様がある」

function myFunction() {
 var sheet = SpreadsheetApp.getActiveSheet();
 var sheetDate1 = sheet.getRange(1, 1).getValue(); // 2017/12/12
 Logger.log(sheetDate1);

 var sheetDate2 = sheet.getRange(1, 2).getValue(); // 2017-12-12
 Logger.log(sheetDate2);

 var gasDate1   = new Date('2017/12/12');
 Logger.log(gasDate1);
 var gasDate2   = new Date('2017-12-12');
 Logger.log(gasDate2);

画像1

Barusu
「ここらへんの詳しい話は偉大な先人が書いたやつ読んでくだせい」

Barusu
「前置き長くなっちゃったけど、ユーティリティ関数の記載はこう」

function dateToFormatString(date, fmt, locale, pad) {
   // %fmt% を日付時刻表記に。
   // 引数
   //  date:  Dateオブジェクト
   //  fmt:   フォーマット文字列、%YYYY%年%MM%月%DD%日、など。
   //  locale:地域指定。デフォルト(入力なし)の場合はja-JP(日本)。現在他に対応しているのはen-US(英語)のみ。
   //  pad:   パディング(桁数を埋める)文字列。デフォルト(入力なし)の場合は0。
   // 例:2016年03月02日15時24分09秒
   // %YYYY%:4桁年(2016)
   // %YY%:2桁年(16)
   // %MMMM%:月の長い表記、日本語では数字のみ、英語ではMarchなど(3)
   // %MMM%:月の短い表記、日本語では数字のみ、英語ではMar.など(3)
   // %MM%:2桁月(03)
   // %M%:月(3)
   // %DD%:2桁日(02)
   // %D%:日(2)
   // %HH%:2桁で表した24時間表記の時(15)
   // %H%:24時間表記の時(15)
   // %h%:2桁で表した12時間表記の時(03)
   // %h%:12時間表記の時(3)
   // %A%:AM/PM表記(PM)
   // %A%:午前/午後表記(午後)
   // %mm%:2桁分(24)
   // %m%:分(24)
   // %ss%:2桁秒(09)
   // %s%:秒(9)
   // %W%:曜日の長い表記(水曜日)
   // %w%:曜日の短い表記(水)
   var padding = function(n, d, p) {
       p = p || '0';
       return (p.repeat(d) + n).slice(-d);
   };
   var DEFAULT_LOCALE = 'ja-JP';
   var getDataByLocale = function(locale, obj, param) {
       var array = obj[locale] || obj[DEFAULT_LOCALE];
       return array[param];
   };
   var format = {
       'YYYY': function() { return padding(date.getFullYear(), 4, pad); },
       'YY': function() { return padding(date.getFullYear() % 100, 2, pad); },
       'MMMM': function(locale) {
           var month = {
               'ja-JP': ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
               'en-US': ['January', 'February', 'March', 'April', 'May', 'June',
                         'July', 'August', 'September', 'October', 'November', 'December'],
           };
           return getDataByLocale(locale, month, date.getMonth());
       },
       'MMM': function(locale) {
           var month = {
               'ja-JP': ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
               'en-US': ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May', 'June',
                         'July', 'Aug.', 'Sept.', 'Oct.', 'Nov.', 'Dec.'],
           };
           return getDataByLocale(locale, month, date.getMonth());
       },
       'MM': function() { return padding(date.getMonth()+1, 2, pad); },
       'M': function() { return date.getMonth()+1; },
       'DD': function() { return padding(date.getDate(), 2, pad); },
       'D': function() { return date.getDate(); },
       'HH': function() { return padding(date.getHours(), 2, pad); },
       'H': function() { return date.getHours(); },
       'hh': function() { return padding(date.getHours() % 12, 2, pad); },
       'h': function() { return date.getHours() % 12; },
       'mm': function() { return padding(date.getMinutes(), 2, pad); },
       'm': function() { return date.getMinutes(); },
       'ss': function() { return padding(date.getSeconds(), 2, pad); },
       's': function() { return date.getSeconds(); },
       'A': function() {
           return date.getHours() < 12 ? 'AM' : 'PM';
       },
       'a': function(locale) {
           var ampm = {
               'ja-JP': ['午前', '午後'],
               'en-US': ['am', 'pm'],
           };
           return getDataByLocale(locale, ampm, date.getHours() < 12 ? 0 : 1);
       },
       'W': function(locale) {
           var weekday = {
               'ja-JP': ['日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日'],
               'en-US': ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
           };
           return getDataByLocale(locale, weekday, date.getDay());
       },
       'w': function(locale) {
           var weekday = {
               'ja-JP': ['日', '月', '火', '水', '木', '金', '土'],
               'en-US':  ['Sun', 'Mon', 'Tue', 'Wed', 'Thur', 'Fri', 'Sat'],
           };
           return getDataByLocale(locale, weekday, date.getDay());
       },
   };
   var fmtstr = ['']; // %%(%として出力される)用に空文字をセット。
   Object.keys(format).forEach(function(key) {
       fmtstr.push(key); // ['', 'YYYY', 'YY', 'MMMM',... 'W', 'w']のような配列が生成される。
   })
   var re = new RegExp('%(' + fmtstr.join('|') + ')%', 'g');
   // /%(|YYYY|YY|MMMM|...W|w)%/g のような正規表現が生成される。
   var replaceFn = function(match, fmt) {
   // match には%YYYY%などのマッチした文字列が、fmtにはYYYYなどの%を除くフォーマット文字列が入る。
       if(fmt === '') {
           return '%';
       }
       var func = format[fmt];
       // fmtがYYYYなら、format['YYYY']がfuncに代入される。つまり、
       // function() { return padding(date.getFullYear(), 4, pad); }という関数が代入される。
       if(func === undefined) {
           //存在しないフォーマット
           return match;
       }
       return func(locale);
   };
   return fmt.replace(re, replaceFn);
}

Barusu
「使い方はこんな感じ。
dateToFormatString(now, '%HH%:%mm%:%ss%');
GASの標準機能でフォーマット変更もできるけど、なんかうまくいかないことがあったのでこのユーティリティ関数を用いた(何がだめだったのかは忘れた)
以下から拝借しております。感謝感謝」

スプレッドシートの範囲から連想配列を作成する

Barusu
「SpreadsheetAppクラスでスプレッドシートを何かしら操作するのだけど、何度も何度もSpreadsheetAppクラスを呼ぶと、動作がすげー遅くなる。
これの回避方法として、予めスプレッドシートの範囲を変数に入れておいて、変数の値を操作するようにする。
その際に二次元配列だと都合が良いケース、連想配列の方が適しているケースがあるので、それぞれ変換できるようにユーティリティ関数にしたのがこちら」

/**
* シートから連想配列を作成する
*
* @param {sheet} sh sheetクラス
* @param {int} lastRowNum shの最終行番号
* @param {int} lastColumNum shの最終列番号
* @param {int} keyColumNum keyにする列の番号
* @return {array} 連想配列 key:id value:id以外の列
*/
function sheetToHash(sh, lastRowNum, lastColumnNum, keyColumNum) {
 var idArray = sh.getRange(2, keyColumNum, lastRowNum, 1).getValues();
 var allArray = sh.getRange(2, 1, lastRowNum, lastColumnNum - 1).getValues();
 var hash = {};
 allArray.forEach(function(value, i) {
   hash[idArray[i]] = value;
 })
 return hash;
}

/**
* カラムのid名から列番号を取得する
*
* @param {array} hash 連想配列
* @param {int} columnNum カラム数
* @param {string} name カラムのid名
* @param {string} keyName hashのkey名
* @return {int} targetColumn 列番号
*/
function getTargetColumn(hash, columnNum, name, keyName){
 var targetColumn = -1;
 var headerRow = hash[keyName];
 for(var i = 0; i < columnNum; i++){
   if(headerRow[i] === name){
     targetColumn = i;
   }
 }
 return targetColumn;
}

連想配列を二次元配列に変換する

Barusu
「スプレッドシートの範囲(rangeオブジェクト)→連想配列 としたものを二次元配列に変換するやつ。
やってることはまぁ単純で、連想配列になっているデータを取り出して二次元配列に入れ直しているだけ」

/**
* 連想配列を2次元配列へ変換する
*
* @param {array} hash 連想配列
* @param {int} columnNum 列数
* @return {array} ms2DimensionArray 2次元配列
*/
function hashTo2DimensionArray(hash, columnNum){
 var ms2DimensionArray = new Array();
 var count = 0;
 for(var key in hash){
   ms2DimensionArray[count] = new Array();
   for(var i = 0; i < columnNum - 1; i++){
     ms2DimensionArray[count][i] = hash[key][i];
   }
   count++;
 }
 return ms2DimensionArray
}

function convertRowToObject(values) {
 // get keys
 var keys = values.splice(0, 1)[0];
 
 // convert row to object
 return values.map(function(row) {
   var object = {};
   row.map(function(column, index) {
     object[keys[index]] = column;
   });
   return object;
 });
}

祝日一覧を取得してシートに転記

Barusu
「勤怠Botのリマインドのときに平日判定が必要なので祝日一覧を取得するユーティリティが必要。
年1回、定期的に動かすものなので、厳密に言えばユーティリティではないけどついでにここに記載した」

var SHEET_URL = "書き込むシートのurlをここに記載する";
var SHEET_NAME = "祝日一覧";

function holiday_main() {
 // 今年の1/1から
 var startDate = new Date();
 startDate.setMonth(0, 1);
 startDate.setHours(0, 0, 0, 0);

 // 来年の12/31まで
 var endDate = new Date();
 endDate.setFullYear(endDate.getFullYear() + 1, 11, 31);
 endDate.setHours(0, 0, 0, 0);  

 var sheet = getholidaysheet();
 var holidays = getHoliday(startDate, endDate);

 var lastRow = sheet.getLastRow();
 var startRow = 1;

 // シートが空白で無いとき、取得した祝日配列の先頭の日付と一致するカラムの位置を探索
 if (lastRow > 1) {
   var values = sheet.getRange(1, 1, lastRow, 1).getValues();
   for(var i = 0; i < lastRow; i++) {
     if(values[i][0].getTime() == holidays[0][0].getTime()) {
       break;
     }
     startRow++;
   }
 }

 sheet.getRange(startRow, 1, holidays.length, holidays[0].length).setValues(holidays);
}

/**
* SHEET_URLで指定したドキュメント内のSHEET_NAMEのシートを取得する
* SHEET_NAMEのシートが存在しない時は、シートを作成する
*/
function getholidaysheet(){
 var ss = SpreadsheetApp.openByUrl(SHEET_URL);
 var sheet = ss.getSheetByName(SHEET_NAME);

 if(sheet == null) {
   sheet = ss.insertSheet(SHEET_NAME);
 }

 return sheet;
}

/**
* startDate〜endDateまでの祝日をgoogleカレンダーの「日本の祝日」から取得
* [日付,祝日名]の多次元配列にした上で返す
*/
function getHoliday(startDate, endDate) {
 var cal = CalendarApp.getCalendarById("ja.japanese#holiday@group.v.calendar.google.com");

 var holidays = cal.getEvents(startDate, endDate);
 var values = [];

 for(var i = 0; i < holidays.length; i++) {
   values[i] = [holidays[i].getStartTime(), holidays[i].getTitle()];
 }

 return values;
}

Barusu
「こちらから拝借しております」

GoogleWorkspaceのユーザー一覧を取得してシートに転記 

Barusu
「これも同様に、ユーティリティではないんだけど月イチで動かすものなのでついでに記載」

function getGsuiteUserList(){

//頭文字検索に使用する文字列 「charAt()」関数で1文字ずつ呼び出す
var searchStrs = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";

//この配列に、メールアドレスを格納する
var allUserMails = new Array();

var domains = ["yourdomain.com"];

for(var i = 0;i < domains.length;i ++){
	for(var j = 0;j < searchStrs.length;j ++){
		
		//呼び出すユーザーの条件を指定する
		var optionalArgs = {
			domain:domains[i],		 //ドメインを入力
			maxResults:500,				//デフォルトでは100件までなので、500(最大数)を指定
			query:"email:"+ searchStrs.charAt(j) +"*",//メールアドレスの頭文字で検索を行う
			fields:"users/emails"	//メールアドレスの情報のみを限定して取得
		};
		
		//ユーザー情報の呼び出し
		var userDatas = AdminDirectory.Users.list(optionalArgs);
		if(userDatas.users){
			//呼び出されたユーザーの回数分ループする
			for(var k = 0;k < userDatas.users.length;k ++){
				//呼び出したメールアドレスには別名も含まれているため、その回数分をループ
				for(var l = 0;l < userDatas.users[k].emails.length;l ++){
					//メールアドレスのうち、primary(一意である)かどうかをチェック
					//念のために、既に取得してあるものは重複しないように対象外にする
					if(userDatas.users[k].emails[l].primary == true &&
						 allUserMails.indexOf(userDatas.users[k].emails[l].address) == -1){
						allUserMails.push(userDatas.users[k].emails[l].address);
					}
				}
			}
		}
	}
}
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('[Gsutie]Mailリスト');
sheet.appendRow(allUserMails);
 
}

Barusu
「はー疲れた。やっぱ物量やべぇよ...」

Barusu
「さて次は勤怠機能を作っていくぞー」

次回予告

1万字を軽く超えて1万3千字に到達したぞ!
次回からは機能実装をしていくぞ!一部ソースコードは記載できないけど、その分解説を増やしていきます!
やっと折返しだけどもう少しお付き合いくださいましー
次回、「勤怠Bot、動きます」。デュエルスタンバイ!

画像2


この記事が気に入ったらサポートをしてみませんか?