🐈
SmartHRから人の情報をとってきて、他SaaSを更新する
背景
SmartHRから各種SaaSへ設定変更、またはその補佐を行うツールを作成しました。このツールは
- SmartHRから従業員情報を取得
- 従業員情報の部署や役職からチームに割り当てる
- チームによって以下SaaSの設定情報を一括設定、または設定の相違を通告する
となっています。
SaaSについて
- kintone
- 申請ワークフローでkintoneのグループを承認者として使用している。そのため部署等の情報によってkintoneのグループを変更したい。
- GoogleWorkSpace
- googleグループ機能を使用しており、異動に合わせてgoogleグループを変更したい。
- slack
- 一部チームはAdmin権限にしたい。
- ユーザーグループを編集したい。
開発ログ
kintone
kintoneにはグループを動的か静的か選ぶことができる。当初、
- CSVでユーザー情報を更新
- ユーザー情報をもとに動的なグループが設定される
でうまくいくかと考えたが、カスタムな文字列をもとに動的なグループを選択することはできないようだ。そこでAPIを使い静的なグループのメンバーを一括設定していくことにする。
GWSのグループにも動的なグループがあったが、動的なグループをどこかのグループに追加することはできないようだ。今の運用ではグループの入れ子を行っているので、これも静的なグループに対してAPIを実施していくことにする。
ただし、kintoneのAPIと違い一括設定ができない(これは一長一短あるが…kintoneは逆に追加するというAPIがないはず)。そこで
- グループのメンバー一覧を取得
- チームのメンバーと比較して、以下を把握
- 追加するべきユーザー
- 削除するべきユーザー
- 「べきユーザー」分だけ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