🐈

SmartHRから人の情報をとってきて、他SaaSを更新する

2024/06/14に公開

背景

SmartHRから各種SaaSへ設定変更、またはその補佐を行うツールを作成しました。このツールは

  1. SmartHRから従業員情報を取得
  2. 従業員情報の部署や役職からチームに割り当てる
  3. チームによって以下SaaSの設定情報を一括設定、または設定の相違を通告する
    となっています。

SaaSについて

  • kintone
    • 申請ワークフローでkintoneのグループを承認者として使用している。そのため部署等の情報によってkintoneのグループを変更したい。
  • GoogleWorkSpace
    • googleグループ機能を使用しており、異動に合わせてgoogleグループを変更したい。
  • slack
    • 一部チームはAdmin権限にしたい。
    • ユーザーグループを編集したい。

https://smarthr.jp/
https://kintone.cybozu.co.jp/
https://workspace.google.com/intl/ja/features/
https://slack.com/intl/ja-jp

開発ログ

kintone

kintoneにはグループを動的か静的か選ぶことができる。当初、

  1. CSVでユーザー情報を更新
  2. ユーザー情報をもとに動的なグループが設定される
    でうまくいくかと考えたが、カスタムな文字列をもとに動的なグループを選択することはできないようだ。そこでAPIを使い静的なグループのメンバーを一括設定していくことにする。

Google

GWSのグループにも動的なグループがあったが、動的なグループをどこかのグループに追加することはできないようだ。今の運用ではグループの入れ子を行っているので、これも静的なグループに対してAPIを実施していくことにする。
ただし、kintoneのAPIと違い一括設定ができない(これは一長一短あるが…kintoneは逆に追加するというAPIがないはず)。そこで

  1. グループのメンバー一覧を取得
  2. チームのメンバーと比較して、以下を把握
    • 追加するべきユーザー
    • 削除するべきユーザー
  3. 「べきユーザー」分だけAPIを実施していく。なお、当ツールはまだ自動追加や削除に対応しておらず、通告を行っている(後々運用実績が貯まれば自動で行う)。

slack

slackも一旦は通告だけを行うことにした。ただし、ユーザーグループを今後柔軟に運用するときにはslackも自動で変更したい。

シート

内部のデータは例。

crewシート

teamシート

役員などの雇用区分や、経理課などのチームもすべてチームとして扱う。

  • チームは0人になってはいけない。
  • チームはなにかのシステム権限を持つ
    • kintoneのグループ
    • Googleグループ
    • slackのユーザーグループ
    • slackのadmin権限

権限シート

smartHRの雇用区分をシステム権限で置き換えるシート。設定用。

code

GASで動かしています。

main.js

// SmartHRからAPIで従業員情報を取得して、crewシートに一括反映する
function refreshCrewSheet(){
  const sheetCrewList = getCrewListFromSheet();
  const employmentTypeDic = getEmploymentTypeDic();
  // ライブラリにてkintoneからメンバー情報をインポート
  const kintoneMemberList = KintoneData.getMemberList();

  // crewシートからシステム権限を抽出して、SmartHRから持ってきた従業員にそれぞれ適用させる
  const outList = getCrewList().map(crew => {
    const sheetCrew = sheetCrewList.find(sc => crew.isSame(sc));
    sheetCrew ? crew.setAuthorityLevel(sheetCrew.getAuthorityLevel()) : crew.generateAuthorityLevel(employmentTypeDic);
    crew.findSetEmail(kintoneMemberList);
    return crew.getOutList();
  });

  refreshSheet(SHEET.crew.name, outList, SHEET.crew.column.id, SHEET.crew.row.data);
}

// teamシートの更新。メンバー数確認用。
function refreshTeamSheet(){
  const crewList = getCrewListFromSheet().filter(c => c.isSameStatus(CREW.status.employed) && c.haveEmail());
  // 手動入力用。SmartHRでは管理できない細かい担当者をチームとして表現するため。なお、outsourcerという名称だが業務委託の方以外も入ってしまったので、後々名前を変えたい
  const outsourcerList = getOutsourcerList();
  const teamList = getTeamListSetCrew(crewList, outsourcerList);

  setList(
    SHEET.team,
    SHEET.team.row.data,
    SHEET.team.column.memberNum,
    teamList.map(t => t.getOutList())
  );
}

// アラートがあればslack通知する
function alert(){
  const crewList = getCrewListFromSheet().filter(c => c.isSameStatus(CREW.status.employed) && c.haveEmail());
  const outsourcerList = getOutsourcerList();
  const teamList = getTeamListSetCrew(crewList, outsourcerList);

  const alertList = [].concat(
    getAlertListTeam(teamList),
    getAlertListGoogleGroup(crewList, teamList),
    getAlertListSlack(crewList, teamList)
  );

  if(alertList.length){
    slackChannel(
      '<webhook>',
      alertList.join('\n')
    );
  }
}

// アラート処理1 Googleグループを比較する
function getAlertListGoogleGroup(crewList, teamList){
  const googleGroupList = teamList.reduce((googleGroupList, team) => {
    return googleGroupList.concat(team.getGoogleGroup());
  }, []).filter(googleGroup => googleGroup !== '');

  return Array.from(new Set(googleGroupList)).reduce((alertList, googleGroup) => {
    const emailList = getGoogleGroupEmailList(googleGroup);

    const alertEmailList = crewList.reduce((alertEmailList, crew) => {
      const joinTeamList = teamList.filter(t => t.isCrew(crew));
      if(joinTeamList.some(t => t.isSameGoogleGroup(googleGroup))){
        if(!emailList.includes(crew.getEmail())) alertEmailList.push(`+ ${crew.getEmail()}`);
      }else{
        if(emailList.includes(crew.getEmail())) alertEmailList.push(`- ${crew.getEmail()}`);
      }
      return alertEmailList;
    }, []);

    if(alertEmailList.length) alertList.push(`${googleGroup} \n${alertEmailList.join('\n')}\n`);
    return alertList;
  }, []);
}

// アラート処理2 チームが0人か確認する
function getAlertListTeam(teamList){
  return teamList.reduce((alertTextList, team) => {
    if(team.isNoCrew()) alertTextList.push(`${team.getName()} のメンバーが0人\n`);
    return alertTextList;
  }, []);
}

// アラート処理3 slackのユーザーグループ、admin権限を確認する
function getAlertListSlack(crewList, teamList){

  // 複数チームに所属している場合、最大の権限と比較するようにしている。そのために各権限にindexを持たせている。
  let slackAuthorityDic = {};
  slackAuthorityDic[MEMBER.status.member.owner.primary] = 5;
  slackAuthorityDic[MEMBER.status.member.owner.normal] = 4;
  slackAuthorityDic[MEMBER.status.member.admin] = 3;
  slackAuthorityDic[MEMBER.status.member.normal] = 2;
  slackAuthorityDic[MEMBER.status.guest.multi] = 1;
  
  // 別にSlackからメンバー情報を出力しているシートがあり、そこからslackのメンバー情報を持ってきている
  const slackMemberList = getMemberListFromSheet();
  
  return crewList.reduce((alertList, crew) => {
    const joinTeamList = teamList.filter(team => team.isCrew(crew));
    const slackMember = find(slackMember => slackMember.isSameEmail(crew.getEmail()));
    
    if(!joinTeamList.length || slackMember === undefined) return alertList;
    const maxSlackAuthority = joinTeamList.reduce((authorityIndex, team) => {
      if(authorityIndex < slackAuthorityDic[team.getSlackAuthority()]) authorityIndex = slackAuthorityDic[team.getSlackAuthority()];
      return authorityIndex;
    }, 0);
      
    if(slackAuthorityDic[slackMember.getStatus()] !== maxSlackAuthority){
      alertList.push(`slack権限相違: ${crew.getName()} slack: ${slackMember.getStatus()} sheet: ${Object.keys(slackAuthorityDic).find(key => slackAuthorityDic[key] === maxSlackAuthority)}`);
    }
 

    return alertList;
  }, []);
  
}

// kintoneグループはalertをあげずにそのまま変更するようにしている。
function updateKintoneGroup(){
  const crewList = getCrewListFromSheet().filter(c => c.isSameStatus(CREW.status.employed) && c.haveEmail());
  const outsourcerList = getOutsourcerList();
  const teamList = getTeamListSetCrew(crewList, outsourcerList);

  const kintoneGroupList = teamList.reduce((kintoneGroupList, team) => {
    const kintoneGroup = team.getKintoneGroup();
    if(kintoneGroup === '') return kintoneGroupList;
    const group = kintoneGroupList.find(group => group.code === kintoneGroup);
    if(group !== undefined){
      group.member = group.member.concat(team.getCrewMailList());
    }else{
      kintoneGroupList.push({
        code: kintoneGroup,
        member: team.getCrewMailList(),
      });
    }
    return kintoneGroupList;
  }, []);

  const kintoneUserCodeList = getUserCodeList();

  // kintoneにユーザーコードがある(=kintoneアカウントを持っている)、かつチームに入っている人でkintoneのグループを更新
  kintoneGroupList.forEach(kg => {
    kg.member = Array.from(new Set(kg.member)).filter(mail => mail.length && kintoneUserCodeList.includes(mail));
    refreshGroupUsers(kg.code, kg.member);
  });
}

sheet.js
const SHEET = {
  crew: {
    name: 'crew',
    row: {
      data: 3,
    },
    column: {
      id: 1,
      empCode: 2,
      name: {
        family: 3,
        last: 4,
      },
      email: 5,
      status: 6,
      authorityLevel: 9,
      departmentList: [10, 12, 14],
    },
  },
  employmentType: {
    name: '[設定]雇用区分',
    row: {
      data: 2,
    },
    column: {
      employment_type: 1,
      authorityLevel: 2,
    },
  },
  team: {
    name: 'team',
    row: {
      data: 3,
    },
    column: {
      name: 1,
      authorityLevel: 2,
      department: 3,
      position: 4,
      memberNum: 5,
      kintoneGroup: 6,
      googleGroup: 7,
      slack: {
        authority: 9,
        usergroupId: 10,
      },
    },
  },
  outsourcer: {
    name: '手動更新',
    row: {
      data: 3,
    },
    column: {
      email: 1,
      teamName: 2,
    },
  },
};

// 従業員情報をシートから取得
function getCrewListFromSheet(){
  return getSheetData(SHEET.crew).map(row => {
    const crew = new Crew();
    crew.setDataFromSheet(row);
    return crew;
  });
}

// 権限の付け合せ情報をシートから取得
function getEmploymentTypeDic(){
  return getSheetData(SHEET.employmentType).reduce((json, row) => {
    json[row[SHEET.employmentType.column.employment_type - 1]] = row[SHEET.employmentType.column.authorityLevel - 1];
    return json;
  }, {});
}

// チームリストを取得
function getTeamList(){
  return getSheetData(SHEET.team).map(row => new Team(row));
}

// チームリストに従業員の情報も設定した上で取得
function getTeamListSetCrew(crewList, outsourcerList){
  return getTeamList().map(t => {
    t.findSetCrewMailList(crewList);
    t.findSetCrewFromOutsourcerList(outsourcerList);
    return t;
  });
}

// 手動入力用のメンバー情報を取得
function getOutsourcerList(){
  return getSheetData(SHEET.outsourcer).map(row => {
    return {
      email: row[SHEET.outsourcer.column.email -1],
      teamName: row[SHEET.outsourcer.column.teamName -1],
    };
  });
}
crew.js
const CREW = {
  status: {
    employed: 'employed',
  },
  authorityLevel: {
    employee: '社員',
  },
};

// 授業員クラス
class Crew{
  constructor(){
    this.rowIndex;
    this.email;
    this.id;
    this.emp_code;
    this.name = {
      last: undefined,
      family: undefined,
    };
    this.departments;
    this.positions;
    this.employment_type;
    this.entered_at;
    this.emp_status;
    this.authorityLevel;
  }

  setDataFromJson(json){

    const getName = (businessName, name) => businessName !== '' ? businessName : name;

    this.id = json.id;
    this.emp_code = parseInt(json.emp_code);
    this.name = {
      last: getName(json.business_first_name, json.first_name),
      family: getName(json.business_last_name, json.last_name),
    };
    this.departmentList = json.departments.map((d, index) => {
      return {
        name: d?.full_name,
        position: json.positions[index]?.name,
      };
    });
    this.employment_type = json.employment_type?.name;
    this.entered_at = json?.entered_at;
    this.emp_status = json.emp_status;
  }
  
  setDataFromSheet(row){
    this.id = row[SHEET.crew.column.id - 1];
    this.name = {
      last: row[SHEET.crew.column.name.last - 1],
      family: row[SHEET.crew.column.name.family - 1],
    };
    this.email = row[SHEET.crew.column.email - 1];
    this.emp_status = row[SHEET.crew.column.status - 1];
    this.authorityLevel = row[SHEET.crew.column.authorityLevel - 1];
    this.departmentList = SHEET.crew.column.departmentList.map(departmentIndex => {
      return {
        name: row[departmentIndex - 1],
        position: row[departmentIndex],
      };
    });
  }

  setAuthorityLevel(authorityLevel){
    this.authorityLevel = authorityLevel;
  }

  findSetEmail(kintoneMemberList){
    const member = kintoneMemberList.find(km => km.isSameId(this.emp_code));
    this.email = member?.getEmail();
  }

  isSame(crew){
    return this.id === crew.id;
  }

  isSameteam(team){
    return team.isSameAuthorityLevel(this.authorityLevel)
      && this.departmentList.some(d => team.isMatchDepartment(d.name)
      && team.isMatchPosition(d.position));
  }

  isSameStatus(emp_status){
    return this.emp_status === emp_status;
  }

  isSameAuthorityLevel(authorityLevel){
    return this.authorityLevel === authorityLevel;
  }

  isMatchDepartment(departmentName){
    return this.departmentList.some(d => d?.name?.includes(departmentName));
  }

  haveEmail(){
    return this.email.length > 0;
  }

  getName(){
    return `${this.name.family} ${this.name.last}`;
  }

  getEmail(){
    return this.email;
  }

  getAuthorityLevel(){
    return this.authorityLevel;
  }

  generateAuthorityLevel(employmentTypeDic){
    this.authorityLevel = employmentTypeDic[this.employment_type];
  }


  getOutList(){
    return [
      this.id,
      this.emp_code,
      this.name.family,
      this.name.last,
      this.email,
      this.emp_status,
      this.entered_at,
      this.employment_type,
      this.authorityLevel
    ].concat(
      this.departmentList.reduce((outList, department) => outList.concat(department.name, department.position), [])
    );
  }
}
team.js
// チームクラス
class Team{
  constructor(row){
    this.name = row[SHEET.team.column.name - 1];
    this.authorityLevel = row[SHEET.team.column.authorityLevel - 1];
    this.department = row[SHEET.team.column.department - 1];
    this.position = row[SHEET.team.column.position - 1];
    this.kintoneGroup = row[SHEET.team.column.kintoneGroup - 1];
    this.googleGroup = row[SHEET.team.column.googleGroup - 1];
    this.slack = {
      authority: row[SHEET.team.column.slack.authority - 1],
      usergroupId: row[SHEET.team.column.slack.usergroupId - 1],
      usergroupMemberList: undefined,
    };
    this.crewMailList = [];
  }

  isSameAuthorityLevel(authorityLevel){
    if(this.authorityLevel === '') return true;
    return this.authorityLevel === authorityLevel;
  }

  isMatchDepartment(department){
    return this.department === '' || department?.includes(this.department);
  }

  isMatchPosition(position){
    return this.position === '' || position?.includes(this.position);
  }

  isCrew(crew){
    return this.crewMailList.includes(crew.getEmail());
  }

  isNoCrew(){
    return this.crewMailList.length === 0;
  }

  isSameName(teamName){
    return this.name === teamName;
  }

  isSameSlackAuthority(slackAuthority){
    return slackAuthority === this.slack.authority;
  }

  isSameGoogleGroup(googleGroup){
    return this.googleGroup === googleGroup;
  }

  findSetCrewMailList(crewList){
    this.crewMailList = crewList.reduce((mailList, crew) => {
      if(crew.isSameteam(this)) mailList.push(crew.getEmail());
      return mailList;
    }, []);
  }

  findSetCrewFromOutsourcerList(outsourcerList){
    outsourcerList.forEach(outsourcer => {
      if(this.isSameName(outsourcer.teamName)) this.crewMailList.push(outsourcer.email);
    });
  }

  setCrewMailList(mailList){
    this.crewMailList = mailList;
  }

  getCrewMailList(){
    return this.crewMailList;
  }

  getName(){
    return this.name;
  }


  getSlackAuthority(){
    return this.slack.authority;
  }

  getKintoneGroup(){
    return this.kintoneGroup;
  }

  getGoogleGroup(){
    return this.googleGroup;
  }

  getUserGroupMemberIdList(){
    if(this.slack.usergroupId === '') return [];
    if(this.slack.usergroupMemberList === undefined){
      this.slack.usergroupMemberList = getUserGroupMemberIdList(this.slack.usergroupId);
    }
    return this.slack.usergroupMemberList;
  }

  getOutList(){
    return [this.crewMailList.length];
  }
}
google.js
// Googleグループのメールリストを取得
function getGoogleGroupEmailList(groupEmail){
  let memberNextPageToken = null;
  let emailList = [];
	
  do {
    let memberlist = AdminDirectory.Members.list(groupEmail, {pageToken: memberNextPageToken});

    if (!memberlist || !memberlist.members) break;
    memberNextPageToken = memberlist.nextPageToken;
    emailList = emailList.concat(memberlist.members.map(m => m.email));
  } while (memberNextPageToken);
  return emailList;
}
kintone.js
// kintoneのグループを更新
function refreshGroupUsers(code, users){
  const options = {
    headers : {
      'Content-type': 'application/json',
      'X-Cybozu-Authorization': PropertiesService.getScriptProperties().getProperty('token'),
    },
    method : 'put',
    "muteHttpExceptions" : true,
    payload : JSON.stringify({
      'code': code,
      'users': users,
    }),
  };
  Logger.log(code);
  const res = UrlFetchApp.fetch(`<ドメイン>/v1/group/users.json`, options);
  Logger.log(res);
}

// kintoneのユーザーコードリストを取得
function getUserCodeList(){
  let userCodeList = [];
  let offset = 0;
  while(true){
    let options = {
      headers : {
        'X-Cybozu-Authorization': PropertiesService.getScriptProperties().getProperty('token'),
      },
      method : 'get',
      "muteHttpExceptions" : true,
      };
    let res = UrlFetchApp.fetch(`<ドメイン>/v1/users.json?offset=${offset}`, options);
    res = JSON.parse(res);
    if(res.users.length === 0) return userCodeList;
    userCodeList = userCodeList.concat(res.users.map(user => user.code));
    offset += 100;
  }
  

  return userCodeList;
}

つくってみて

今は設定の誤りを検知する機能がメインだが、今後は設定自動化に向けて改善していく。

ランサーズ株式会社

Discussion