PCの棚卸しを自動化した話

こんにちは 現在は技術本部でヘルプデスクやその他色々をしているヘルプデスクオペレーターの戸田です。

今回は、Slackを使ってPCの棚卸しを大幅に工数削減した話になります。

PCの棚卸し作業とは

PCの棚卸し作業とは、従業員の誰がどのPCを使っているかの確認をする作業のことを指しています。弊社では800台近くのPCを管理しているため、今誰が使っているのかをチェックする必要があります。

作業内容としては、

  1. 社員にPCを持っているか確認し、回答を得る
  2. その回答とPC台帳をマッチングして整合しているか確認する
  3. 整合していなかったらヒヤリング・調査をして所在を明確にする
  4. 結果をもとに台帳を最新化する

この棚卸しの中身はどの会社でも変わらないと思います。

弊社の棚卸しの問題点

棚卸しをする方法として色々な方法があるかと思いますが、弊社ではリモートワークを採用しているため、現物を一台一台確認する方法はなかなか取れません。

また、従業員一人ひとりに連絡を取って、どのPCを使っているかを聞くことも非常に効率が悪いですし、ユーザー側も手間です。

さらに、現状アドウェイズは子会社の端末も調達を行っているため、ヒヤリングする守備範囲がとても広いです。

取り組んだこと

前述の問題を解決するために、SlackとGoogleフォームを使い、回答をするスピードをあげつつリマインドを自動化しながら棚卸しをしました。

Googleフォーム

こちらはどの子会社でもアクセスできるような場所に設定しました。もちろん、社外のアカウントからアクセスは禁止しています。

ユーザー側は特に不自由することなくアンケートに記入をすることができるようになりました。

また、複数台もっていることもあるため、複数記入することを考慮したアンケートとなっております。

スプレッドシート

Googleフォームでアンケートを作るとスプレッドシートに回答を集めることができます。

今回は、アンケート結果と聞きたい人のリストを関数で比較して、回答されていたらTrue、回答されていなかったらFalseと表示するようにし、GASで未回答者の判断をしてリマインドできるようにしました。

また、基本的には回答をしてほしいのですが、休職をしている人などの特別対応をしないといけない人がいたりします。 そのため、特別対応をするために、回答不要の選択肢を追加しました。

こちらもGASで回答不要の判断をしており、対象者であればスキップするようにしました。

GAS

どうすればスピードをあげつつ運用コストをかけずに未回答者にリマインドをできるか考えた結果、SlackのBotを使って通知を促すしかないという結論になりました。ただBotからリマインドをするためには、まず送りたい相手のユーザーのIDを把握しないといけません。

考えなくてはいけない機能は、下記の3つになります。

  • SlackのIDの判別
  • 未回答者の判別
  • 未回答者にSlackで連絡

まずは、SlackのIDの判別について考えました。

弊社の環境では2つ問題がありました。

SlackのIDを取得するときにはAPIの users.lookupByEmail メソッドを使うことが多いと思いますが、これではAPIの上限を超えてしまう点と、複数のワークスペースがあり、オーガナイゼーション単位でユーザーの一覧を取得することができない点でした。

問題を解決するために、SlackのIDの取得に users.lookupByEmail ではなく、users.list を使いました。さらに、各ワークスペース単位で users.list を実行することで、ユーザーの一覧を取得することを実現しました。

別の案件でユーザーIDの一覧がほしいこともあったため、今回はスプレッドシートに出すようにしていました。

const slackApiUrl = 'https://slack.com/api/';
const apiUserlist = slackApiUrl + 'users.list';
const slackToken = '';
const slackTeamId = ['A','B','C','D']; //ほしいワークスペースのIDを記載する
let arr = [];

function slackid() {
  for (let i = 0; i < slackTeamId.length; i++) {
    console.log(slackTeamId[i])
    getUserList(slackTeamId[i])
  }
}

function getUserList(slackTeamId){   
  let is_loop = true;
  let is_first = true; 
  let nextCorsor = '';  

  while (is_loop) {
    const payload = {
      token: slackToken,
      limit: 1000,
      cursor: nextCorsor,
      team_id: slackTeamId,
    };
    const options = {
      method: 'GET',
      payload: payload,
      headers: {
        contentType: 'x-www-form-urlencoded',
      },
    };
    
    const response = UrlFetchApp.fetch(apiUserlist, options);
    const members = JSON.parse(response).members;
    const contentText = JSON.parse(response.getContentText());
    if (contentText.response_metadata.next_cursor != ''){
      nextCorsor = contentText.response_metadata.next_cursor;
    }
    else{
      nextCorsor = '';
    }
    
      
    for (const member of members) {
      //botユーザー、Slackbotを除く
      if (!member.is_bot && member.id !== 'USLACKBOT') {
        let id = member.id;
        let deleted = member.deleted;
        let real_name = member.profile.real_name; //氏名
        let name = member.name; //mei
        let is_primary_owner = member.is_primary_owner; //プライマリオーナー
        let is_owner = member.is_owner; //オーナー
        let is_admin  = member.is_admin; //管理者アカウント
        let is_restricted = member.is_restricted; //Trueならばゲスト
        let email = member.profile.email;
        arr.push([id,deleted ,real_name, name,email, is_primary_owner, is_owner, is_admin , is_restricted,]);
      }
    }
      
    if (is_first === false && nextCorsor === '') {
      is_loop = false;
    }
    is_first = false;
  }


  //スプレッドシートに書き込み
  const sheetId = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('リスト');
  sheetId.clear();
  sheetId.appendRow(['ID','削除済','氏名','ユーザー名','email','プライマリオーナー','オーナー','管理者アカウント','ゲストアカウント']);
  sheetId.getRange(sheetId.getLastRow()+1, 1, arr.length, arr[0].length).setValues(arr);

これでSlackのIDを取るところは完成しました。

あとは、未回答者を判別するところと、Slackで送るところになります。

上記2点に関してはクラメソさんの記事を参考にさせて頂きつつ、同じようなGASを書いてあったため、そちらを流用しました。

作成したスクリプトの全体像は以下です。

  • handler.gs
    • 未回答者にリマインダーを送信するための関数 unansweredMemberRemind を定義し、処理の起動元となるスクリプト
  • userAnswerDto.gs
    • ユーザー回答を扱うためのクラス UserAnswerDto を定義するスクリプト
  • userAnswerRepository.gs
    • スプレッドシートからユーザーの回答状況を取得するための UserAnswerRepository クラスを定義するスクリプト
  • userAnswereService.gs
    • ユーザーの回答状況を処理するための UserAnswereService クラスを定義するスクリプト
  • unansweredMemberReminderUsecase.gs
    • 未回答者にリマインダーを送信するための UnansweredMemberReminderUsecase クラスを定義するスクリプト
  • slack.gs
    • Slack 用の API 操作を提供する Slack クラスを定義するスクリプト

handler.gs

var exports = exports || {};
var module = module || { exports: exports };
exports.unansweredMemberRemind = void 0;
function unansweredMemberRemind() {
    //プロパティ取得
    var slackToken = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
    var adminSlackToken = PropertiesService.getScriptProperties().getProperty('ADMIN_SLACK_TOKEN');
    if (slackToken === null)
        throw new Error('[プロパティ:SLACK_TOKEN]が設定されていません。');
    var slack = new Slack(slackToken, adminSlackToken);
    var userAnswerRepository = new UserAnswerRepository();
    var service = new UserAnswereService(userAnswerRepository, slack);
    var usecase = new UnansweredMemberReminderUsecase(service, slack);
    usecase.run();
}
exports.unansweredMemberRemind = unansweredMemberRemind;

userAnswerDto.gs

// Compiled using practice 1.0.0 (TypeScript 4.9.5)
var exports = exports || {};
var module = module || { exports: exports };
exports.UserAnswerDto = void 0;
/**
 * ユーザー回答クラス
 */
var UserAnswerDto = /** @class */ (function () {
    /**
     * コンストラクタ
     * @param mailAddress メールアドレス
     * @param isAnswered 回答済み
     * @param isNoAnswerRequired 回答対象
     */
    function UserAnswerDto(mailAddress, isAnswered, isNoAnswerRequired) {
        this.mailAddress = mailAddress;
        this.isAnswered = isAnswered;
        this.isNoAnswerRequired = isNoAnswerRequired;
    }
    /**
     * 通知対象かどうかを取得する。
     * @param userAnswer
     * @returns
     */
    UserAnswerDto.prototype.isInvitationTarget = function () {
        return this.isAnswered.toUpperCase() === "FALSE" && !this.isNoAnswerRequired;
    };
    return UserAnswerDto;
}());
exports.UserAnswerDto = UserAnswerDto;

userAnswerRepository.gs

// Compiled using practice 1.0.0 (TypeScript 4.9.5)
var exports = exports || {};
var module = module || { exports: exports };
exports.UserAnswerRepository = void 0;
//import { UserAnswerDto, } from '#/repository/dto/userAnswerDto';
/**
 * ユーザー回答状況クラス
 */
var UserAnswerRepository = /** @class */ (function () {
    /**
     * コンストラクタ
     */
    function UserAnswerRepository() {
        /**
         * データ取得開始行数
         */
        this.DATA_START_ROWNUM = 2;
        /**
         * データ取得開始列数
         */
        this.DATA_GET_COLNUM = 1;
        /**
         * データ取得数
         */
        this.DATA_GET_SIZE = 13;
        /**
         * メールアドレスインデックス
         */
        this.USER_MAIL_ADDRESS_IDX = 2;
        /**
         * 回答済みインデックス
         */
        this.IS_ANSWERED_IDX = 3;
        /**
         * 回答不要インデックス
         */
        this.IS_NO_ANSWER_REQUIRED_IDX = 4;
        var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('test');
        if (sheet === null)
            throw new Error('「test」シートが存在しません。');
        this.sheet = sheet;
    }
    /**
     *
     * @returns
     */
    UserAnswerRepository.prototype.getAnswerStatus = function () {
        var _this = this;
        var endRow = this.sheet.getLastRow();
        if (endRow < this.DATA_START_ROWNUM)
            return [];
        var records = this.sheet.getRange(this.DATA_START_ROWNUM, this.DATA_GET_COLNUM, endRow - (this.DATA_START_ROWNUM - 1), this.DATA_GET_SIZE).getValues();
        return records.filter(function (values) {
            return values[_this.USER_MAIL_ADDRESS_IDX] !== '';
        }).map(function (values) {
            return new UserAnswerDto(values[_this.USER_MAIL_ADDRESS_IDX], values[_this.IS_ANSWERED_IDX], values[_this.IS_NO_ANSWER_REQUIRED_IDX]);
        });
    };
    return UserAnswerRepository;
}());
exports.UserAnswerRepository = UserAnswerRepository;

userAnswereService.gs

// Compiled using practice 1.0.0 (TypeScript 4.9.5)
var exports = exports || {};
var module = module || { exports: exports };
exports.UserAnswereService = void 0;
//import { UserAnswerRepository, } from '#/repository/userAnswerRepository';
//import { Slack, } from '#/util/slack/slack';
/**
 *
 */
var UserAnswereService = /** @class */ (function () {
    /**
     * コンストラクタ
     * @param userAnswerRepository
     * @param slack
     */
    function UserAnswereService(userAnswerRepository, slack) {
        this.userAnswerRepository = userAnswerRepository;
        this.slack = slack;
    }
    /**
     *
     * @returns
     */
    UserAnswereService.prototype.getUnanswerdSlackIds = function () {
        var _this = this;
        //ユーザーからの回答状況を取得する
        var userAnswerList = this.userAnswerRepository.getAnswerStatus();
        //未回答ユーザーのメールアドレスを取得する
        var mailAddressList = userAnswerList.filter(function (userAnswer) {
            return userAnswer.isInvitationTarget();
        }).map(function (userAnswer) {
            return userAnswer.mailAddress;
        });
        //メールアドレスからSlackIDへ変換する。
        var slackUserIdList = [];
        mailAddressList.forEach(function (mailAddress) {
            try {
                var id = _this.slack.getUserIdByEmail(mailAddress);
                slackUserIdList.push(id);
            }
            catch (e) {
                console.warn(e);
            }
        });
        return slackUserIdList;
    };
    return UserAnswereService;
}());
exports.UserAnswereService = UserAnswereService;

unansweredMemberReminderUsecase.gs

// Compiled using practice 1.0.0 (TypeScript 4.9.5)
var exports = exports || {};
var module = module || { exports: exports };
exports.UnansweredMemberReminderUsecase = void 0;
//import { Slack, } from '#/util/slack/slack';
//import { UserAnswereService, } from '#/service/userAnswereService';
/**
 * 未回答者リマインドユースケース
 */
var UnansweredMemberReminderUsecase = /** @class */ (function () {
    /**
     * コンストラクタ
     * @param service
     * @param slack
     */
    function UnansweredMemberReminderUsecase(service, slack) {
        this.service = service;
        this.slack = slack;
        this.remindMessage = "test";

    }
    /**
     * リマインド処理
     */
    UnansweredMemberReminderUsecase.prototype.run = function () {
        var _this = this;
        //未回答者のSlackID一覧を取得し、DMを送付する。
        var slackUserIdList = this.service.getUnanswerdSlackIds();
        slackUserIdList.forEach(function (slackUserId) {
            _this.slack.postMessage(slackUserId, _this.remindMessage);
        });
    };
    return UnansweredMemberReminderUsecase;
}());
exports.UnansweredMemberReminderUsecase = UnansweredMemberReminderUsecase;

slack.gs

// Compiled using practice 1.0.0 (TypeScript 4.9.5)
var exports = exports || {};
var module = module || { exports: exports };
exports.Slack = void 0;
/**
 * Slack用API
 */
var Slack = /** @class */ (function () {
    /**
     * コンストラクタ
     * @param apiToken APIトークン
     * @param spreadsheetId スプレッドシートのID
     */
    function Slack(apiToken, spreadsheetId) {
        this.apiToken = apiToken;
        this.spreadsheetId = "xxxxxx";
        this.URL = 'https://slack.com/api/';
        this.METHOD_POST_MESSAGE = 'chat.postMessage';
    }

    /**
     * メッセージを送信する。
     *
     * @param userId ユーザーID
     * @param plainText 送信メッセージ(平文)
     * @return 送信成否
     */
    Slack.prototype.postMessage = function (userId, plainText) {
        var param = "".concat(this.METHOD_POST_MESSAGE);
        var payload = {
            'channel': "".concat(userId),
            'text': "".concat(plainText)
        };
        this.post(param, payload);
        return true;
    };

    /**
     * Slackへメッセージを送信する。
     * @param method
     * @param payload
     * @returns
     */
    Slack.prototype.post = function (method, payload) {
        var sendUrl = this.URL + method;
        var options = {
            'headers': this.createHeader(),
            'method': 'post',
            'contentType': 'application/json;charset=UTF-8',
            'payload': JSON.stringify(payload)
        };
        return UrlFetchApp.fetch(sendUrl, options).getContentText();
    };
    /**
     * 
     * @returns
     */
    Slack.prototype.createHeader = function () {
        return {
            'Authorization': 'Bearer ' + this.apiToken
        };
    };
    /**
     * メールアドレスに基づいてSlackのユーザーIDを取得する。
     * @param {string} email 検索するメールアドレス
     * @returns {string | null} ユーザーID。見つからない場合はnull。
     */
    Slack.prototype.getUserIdByEmail = function (email) {
        var sheet = SpreadsheetApp.openById(this.spreadsheetId).getSheetByName('リスト'); // スプレッドシートからシートを取得
        var emailColumn = 5; // メールアドレスが格納されている列(E列)
        var idColumn = 1; // ユーザーIDが格納されている列(A列)
        var lastRow = sheet.getLastRow();
        var emailRange = sheet.getRange(1, emailColumn, lastRow, 1);
        var emailValues = emailRange.getValues();

        for (var i = 0; i < lastRow; i++) {
            if (emailValues[i][0] === email) {
                // メールアドレスが一致した場合、対応するユーザーIDを取得して返す
                var idRange = sheet.getRange(i + 1, idColumn);
                return idRange.getValue();
            }
        }

        // メールアドレスが見つからない場合はnullを返す
        return null;
    };

    return Slack;
}());
exports.Slack = Slack;

これで実現したい機能は完成しました。

効果

結果このようなリマインドを行うことができました。

アンケートの収集率に関してですが、最初の告知の翌日には30%〜40%の回答が収集でき、2,3日で80%〜90%くらいが集まりました。また、未回答者に対するリマインドを自動化することで工数の大幅削減を実現することができました。

正直、80%くらい集まったときは、「え、こんなにすぐ集まる??」という感想が出ました。

Slackで告知されてリマインドもSlackで来てくれてうれしいという声もありましたので、個人的には嬉しかったです。

棚卸しを行う側としては、

  • 自動で未回答者にはリマインドをしてくれる点
  • アンケート結果を簡単に収集できるようになった点

が非常に作業が少なくなり効率化できたため、よかったなと思っています。

改善点

今回GASを使って改善を行いましたが、私がいる部署は全員GASを含めたコードがかけるわけではありません。

そのため、保守性があるわけではないため、ノーコードや別のSaaSとかで解決できればいいのかなと思っております。が、GASのほうが柔軟性が高いのも事実なのでどうしたものかなと思っております。

まとめ

情シスの仕事の関係上、社員に対してヒヤリングをしないといけない状況は必ずあって改善ができたらといいなと思っていました。

リマインドを改善するためにGASで自動化しましたが、大幅に改善ができてよかったかなと思っています。

最後までお読み頂きありがとうございました!