Unipos engineer blog

Uniposの開発者ブログ

社食を支える技術(前編)

Fringe81アドベントカレンダーの15日目です。

qiita.com

こんにちは、Fringe81エンジニアの小紫です。
弊社では社員食堂としてみんなの食堂を利用しています。

www.wantedly.com

温かくて健康的な食事を安くでいただけるサービスなのですが、日々利用するには運用を行う必要があります。
Webサービスと同じですね。
具体的に運用とは、どぶろっくが取材に来たときの動画でも説明がありますが、 社食の準備や片付けのことを指しています。
そしてこれらを行う給食当番は社員から数名ランダムに選出して毎日行っています。
社食なのに給食とはこれいかに、という感じですが語感とか馴染みやすさからこの呼称が定着しています。

また、社員食堂の利用料を一部会社が福利厚生という形で補助するため「誰が何回利用したか」を記録する必要があります。
詳しいくことは省きますが税金の計算上、社食の利用チェックは非常に重要なのです。

まとめると

  • 毎日社員から数名を給食当番として選出する
  • 社食を誰が何回利用したかを記録する

を解決する必要が出てきました。

給食当番をどうやって選ぶか

まずは「毎日社員から数名を給食当番として選出する」というやつです。
これを分解してみると以下のようになりました。

  • 給食当番の選出
    • 選出される対象の一覧を取得
    • 対象者から数名をランダムに選択
      • 個別の事情により当番出来ない人を除外
      • 負担を考慮して頻度が高くなりすぎないように調整
  • 本人たちへ通知
    • Slackでメンションを飛ばす
    • 給食当番の準備片付けの時間にSlackで再度メンション

それぞれについて説明していきます。

技術選定

大前提としての技術選定。
給食当番選出のためにサーバを用意するのは避けたいしお金も可能なら0円で抑えたい。
そこで開発にはGoogle Apps Script(GAS)を採用しました。
developers.google.com

雑に紹介しておくとGoogleスプレッドシートをデータベースとして利用しつつJSで書いたアプリケーションを動かすことが出来ます。
Cron的な定期実行もサポートしていたりと至れり尽くせりですね。
ドキュメント: https://developers.google.com/apps-script/guides/triggers/installable#time-driven_triggers
流行りに乗るならサーバレスという単語で表現出来るプラットフォームです。

GASだとES2015とかの今どきな文法がどの程度サポートされているかよくわからないので懐かしい感じのJSが書けて牧歌的でいいですね。

給食当番の選出

選出される対象の一覧を取得

普通にやるなら人事から社員一覧を貰ってやるのを思い付きますが、作業の自動化が非常に難しくなってしまいますね。
自動化するには社員一覧、たとえばExcelファイルを更新したらイベントフックでFaaS(Lambda的なの)等を動かして...と異常に複雑になってしまいます。
Excelも触りたくないし。

そこで弊社ではSlackを利用しているので、そこの社員チャンネルに在籍している人を取得してくることにしました。
事前準備としてSlack APIから「Create New App」してトークンやら何やらを手に入れておきます。

GASからSlackをいい感じに使うためにはSlackAppを利用します。

qiita.com

これを用いて社員全員がjoinしている特定のチャンネルの情報を取得し、そのメンバ一覧を取得します。 privateチャンネルの場合はgroupという呼び方をするので注意が必要です。

var slackApp = SlackApp.create(slackToken);

var getChannelMembers = function(channelName) {
  var channels = slackApp.groupsList(false).groups;  // 特定のprivateチャンネルのものだけを一発で取得するのは出来なさそう
  
  for (var i = 0; i < channels.length; i++) {
    if (channels[i].name == channelName) {
      return channels[i].members;
    }
  }
  return null;
};

この中から有効なメンバーだけを取得しないといけないので各メンバーの詳細情報をSlack API経由で取得しつつfilterします。

var getAllMembers = function() {
  var members = getChannelMembers(targetChannelIsPublic, targetChannel);
  var channelMemberInfos = []
  for (var i = 0; i < members.length; i++) { 
    var memberId = members[i];
    var userInfoRes = slackApp.usersInfo(memberId);

    if (!userInfoRes.ok) { break; }
    var { user: userInfo } = userInfoRes;
    if (!userInfo.deleted && !userInfo.is_bot && !userInfo.is_restricted && !userInfo.is_ultra_restricted) {
      // 有効なメンバーの時のみ
      channelMemberInfos.push({id: userInfo.id, name: userInfo.name});
    }
  }  
  return channelMemberInfos;
};

これで有効なメンバーのSlack IDと名前を取得することが出来ました。

対象者から数名をランダムに選択

単にランダムでいいのであれば、先程取得したメンバーから適当に数人選べば良いだけです。

getAllMembers().sort(function(i, j) {
  // シャッフル
  return Math.random() - 0.5; // 偏るらしいが無視
}).slice(0, N); // 先頭N人を選択

以下のような観点からこのような単純な実装は望ましくないかと。

  • 事情により選出してはいけない人がいる
    • 弊社の場合だと本社ではなく関西支社勤務の人や産休/育休中の人など
  • 同じ人が数日連続で選出される可能性がある
    • 人数が多ければ無視できるレベルかも知れない

ということでこれらを考慮した実装をする必要があります。
そこで、

  • メンバーごとに給食当番が可能かどうかのフラグを持たせる
    • ignoredフラグがtrueなら選出しないようにする
  • メンバーごとに給食当番を行った回数を保存する
    • 回数が少ない人たちから選出する

という感じで対応することにしました。

フラグを持たせたり回数を保存しておく必要が出てきたので、選出に際して都度メンバー一覧を取得してきてシャッフルするだけではだめになりました。
なので、事前に社員リストを取得しておき、一部のメンバーについてはフラグを立てておき、給食当番として選出するために回数をインクリメントしていく、という実装が必要になります。
どうでもいいですが、このあたりから「おや、意外と面倒くさいぞ」となりました。

データ構造としては単純に[name, memberId, ignored, count]としておきます。
これらの情報をスプレッドシートの特定のシートに保存しておきます。

目指す形は↓のような形式。

f:id:fringeneer:20181215095437p:plain
データ構造

まず、メンバー一覧を取得してきて保存するあたりの実装はこんな感じ。

function updateMemberList() {
  // 対応するスプレッドシートの'member'シートを取得
  var memberSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('member');

  // メンバー一覧を更新するデータ
  var newData = (function() {
    // 現在のデータを取得する
    var allCountValueArray = memberSheetRange.getValues();
    var allMemberValue = {}; // { memberId: { ignored, count }な形
    var maxCount = 0;
    for (var i = 0; i < allCountValueArray.length; i++) {
      var memberId = allCountValueArray[i][1];
      var ignored = allCountValueArray[i][2] === true;
      var count = allCountValueArray[i][3];
      allMemberValue[memberId] = { ignored: ignored, count: count };
      if (maxCount < count) { maxCount = count };
    }
    // slackから一覧取得
    var allMembers = getAllMembers();
    
    return allMembers.map(function(memberInfo) {
      var { id: memberId, name: memberName } = memberInfo;
      if (allMemberValue.hasOwnProperty(memberId)) {
        // 現行メンバーの給食当番した回数を保持
        var { ignored: ignored, count: count } = allMemberValue[memberId]
        return [memberName, memberId, ignored, count];
      } else {
        // 新規メンバーを追加
        // 新規メンバーが連続して当番しなくて良いように、countは現在の最大値にしておく
        return [memberName, memberId, false, maxCount];
      }
    });   
  })();
  
  if (newData) { // データが取れていれば
    // 値の更新
    memberSheet.getRange(2, 1, newData.length, 4).setValues(newData);
  }
}

毎日の選出とは別で定期的に実行してメンバーを更新していくイメージです。
新規メンバーの回数を0にしておくといきなり数日連続でやらないといけなくなってしまったりするので、その辺は調整しています。

続いて、給食当番として数人を適当に選出するあたりの実装です。

var choiceTodayMembers = function() {
  // 保存済みの一覧を取得してくる
  var memberRange = memberSheet.getRange(2, 1, memberSheet.getLastRow(), 4);
  var allCountValueArray = memberRange.getValues();  
  
  // これまでの回数でsortしておく
  allCountValueArray.sort(function(a, b) {    
    // [memberName, memberId, ignored, count]という形式
    var res = a[3] - b[3];
    if (res === 0) {
      // 回数が同じならランダムで並び替える
      return Math.random() - 0.5; // ちょっと乱数が偏るらしいけど無視
    } else {
      return res; // 回数の昇順で並び替える
    }
  });
  
  // 今日の当番を選択する
  var todayMemberIds = allCountValueArray.filter(function(m) {
    var [name, id, ignored, count] = m;
    // ignored === TRUEなら除外する
    return !ignored;
  })
  .slice(0, N) // 先頭からN人選ぶ(上のsortで、回数が少ない人がランダムで並んでるはず)
  .map(function(m) { return m[1]; }); // idだけ抜き出す

  // 値の更新
  var newValueArray = allCountValueArray.map(function(m) {
    var [name, id, ignored, count] = m;
    if (todayMemberIds.indexOf(id) >= 0) {
      return [name, id, ignored, count + 1]; // 回数をインクリメントしておく
    } else {
      return m; // 選ばれなかった人は何もしない
    }
  });
  memberSheet.getRange(2, 1, newValueArray.length, 4).setValues(newValueArray);
  
  return todayMemberIds; // 返り値は[選出されたメンバーのSlack ID]
};

これらの実装の結果、

  • 特定のSlackチャンネルから候補者一覧を取得
  • メンバーごとに給食当番が可能かどうかのフラグを持たせる
  • メンバーごとに給食当番を行った回数を保存する
  • 給食当番が可能かつ給食当番を行った回数が少ない人から優先でN人を選ぶ

という処理が可能になりました。

Slackで通知して周知

紆余曲折を経て、誰が給食当番をやるかを決定することが出来ました。
続いてはそれを本人たちおよび皆に周知する必要があります。 単純に全員が入っているSlackチャンネルにpostして、当番の人たちにメンションを飛ばすことで実現できそうです。

// 選ばれた人たちに通知を送る
var notifyToTodayMember = function(memberIds) {
  var today = (function() { // yyyy/mm/dd形式
    var dt = new Date();
    return dt.getFullYear() + "/" + (dt.getMonth() + 1) + "/" + dt.getDate();
  })();
  
  // 神のお告げっぽいメッセージ
  var message = [":kami: <「今日(" + today + ")の給食当番はあなた達だ。",
                 "       【11:30~準備、13:15~片付け】をよろしくお願いするぞ。",
                 "       もし都合が悪ければ周りで各自で代打を見つけるように。"];
  
  // メンションするときにいい感じにするため、Slack IDから詳細を取得する
  var getMemberInfo = function(memberId) {
    return slackApp.usersInfo(memberId);
  };
  var memberInfoArray = memberIds.map(getMemberInfo).forEach(function(memberInfo) {
    var user = memberInfo.user;
    message.push("  ・" + user.real_name + "さん <@" + user.id + "|" + user.name +">");
  });
  var targetChannelUrl = 'https://hooks.slack.com/services/A/B/C';  // 特定のchannelにpostするためのwebhook url
  // SlackにHTTPリクエスト送信
  sendSlackText(message.join('\n'), targetChannelUrl);
};

// Slackにメッセージを送信する
var sendSlackText = function(msg, url) {
  var options = {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify({ text: msg }) // user, iconなどはSlack appの方で設定を変更する
  };
  
  UrlFetchApp.fetch(url, options); // 本当は例外処理が必要
}

これで通知するとこんな感じ。
iconとか名前はSlack Appの設定からいじることが出来ます。

f:id:fringeneer:20181215095204p:plain
神のお告げ風

給食当番の準備片付けの時間にSlackで通知

これでSlackに通知することは出来ましたが、問題は給食当番が当番であることを忘れてしまうというのが発生してしまいました。
ということでリマインドを送りたいという欲求が出てきます。
しかし、GASのドキュメントによると時間によるトリガーは揺らぎが発生するとのこと。

The time may be slightly randomized — for example, if you create a recurring 9 a.m. trigger, Apps Script chooses a time between 9 a.m. and 10 a.m.

つまり、11:30にリマインドしたいのにGASを使うと11:00~11:59のどこかでのリマインドとなってしまいます。。
そこで代替案としてSlackのリマインダを利用することとしました。

使い方は簡単で、以下のようにすればGASからリマインダをセットすることが出来ます。

// 今日の担当にリマインドを送る
var addReminders = function(memberId) {
  var addSlackReminder = function(json) {
    var slackUrl = 'https://slack.com/api/reminders.add';
    var options = {
      method: 'post',
      contentType: 'application/json; charset=utf-8',
      headers: { Authorization: 'Bearer ' + slackToken },
      payload: JSON.stringify(json)
    };
    
    UrlFetchApp.fetch(slackUrl, options);
  }

  addSlackReminder({ 
    text: "社食の準備をお願いします!",
    time: "at 11:30 JST Today",
    user: memberId
  });
  
  addSlackReminder({ 
    text: "社食の片付けをお願いします!",
    time: "at 13:15 JST Today",
    user: memberId
  });
};

残念なポイントがあって、Slackのreminders.addを使うとリマインダの作成者がbotではなくてbotを作成したユーザになってしまうことです。
給食当番みんなに個別でリマインダを設定することになるので↓のような通知が来てしまいます。。

f:id:fringeneer:20181215095233p:plain
Slackリマインダを完了した通知が来てしまう

少なくともこの記事執筆時点ではreminders.addのパラメータに作成者にあたるものを設定できないので諦めるしかないですね。

社食の利用チェック

ここまでで随分長くなってしまったので、ここからは後編で後日。