皆さんこんにちは。kintoneアプリエンジニアのtomiokaです。
先日、人材紹介会社のお客様からこんな相談をいただきました。「求職者と案件のマッチングが担当者の記憶頼みで、情報共有ができていない」と。
今回の記事は、営業10〜20名規模の人材紹介会社でキャリアアドバイザー(CA)をされている方に向けて書いています。求職者の希望条件と求人案件の突き合わせを、担当者の記憶とExcelのフィルタでやっている。そんな状況を、kintoneで仕組み化した話です。
「マッチングは経験と勘」の限界
具体的には、「マッチングの情報が社内で共有できていない」とのことでした。
聞けば、CA(キャリアアドバイザー)が10名ほどいるのに、誰がどの求職者にどの案件を紹介したかが共有されていない。求職者の情報はExcelで管理していて、求人案件は別のExcelファイル。マッチングは各CAが自分の頭の中でやっている。紹介履歴は個人のメモにしか残っていない。同じ求職者に別のCAが違う案件を紹介してしまい、求職者からクレームにつながったこともあったそうです。
この状態だと、以下の問題がほぼ必ずと言っていいほど発生します。
- 同じ求職者に複数のCAが別々にアプローチしてしまう
- 求職者の希望条件が更新されても、他のCAが知らない
- 「この求職者に合いそうな案件」を探すのに毎回Excelをフィルタしまくる
- 紹介して不採用になった案件を、別のCAがまた紹介してしまう
- 月末にならないと「今月何件マッチングしたか」がわからない
お客様も「感覚でやるのが限界だとは思っていたけど、何から手をつければいいかわからなかった」とおっしゃっていました。
kintoneでどう解決したか
構成は3つのアプリです。
| アプリ名 | 役割 | 主なフィールド |
|---|---|---|
| 求職者管理 | 求職者の基本情報と希望条件 | 氏名、希望職種、希望年収、経験業界、スキル、担当CA、ステータス |
| 求人案件管理 | 求人企業と案件の情報 | 企業名、職種、年収帯、必要スキル、勤務地、ステータス |
| マッチング履歴 | 誰に何を紹介したかの記録 | 求職者(ルックアップ)、求人案件(ルックアップ)、紹介日、結果、不採用理由 |
ポイントは、3つ目の「マッチング履歴」アプリです。求職者と求人案件を紐づける中間テーブルの役割を果たします。これがあることで「この求職者に過去何件紹介したか」「この案件に何人紹介したか」が双方向で見えるようになります。
求職者アプリのフィールド構成
| フィールド名 | フィールドタイプ | フィールドコード |
|---|---|---|
| 氏名 | 文字列(1行) | applicant_name |
| フリガナ | 文字列(1行) | applicant_kana |
| メールアドレス | 文字列(1行) | |
| 電話番号 | 文字列(1行) | phone |
| 生年月日 | 日付 | birth_date |
| 希望職種 | チェックボックス | desired_job_types |
| 希望年収(万円) | 数値 | desired_salary |
| 希望勤務地 | チェックボックス | desired_locations |
| 経験業界 | チェックボックス | experience_industries |
| 保有スキル | チェックボックス | skills |
| 職務経歴概要 | 文字列(複数行) | career_summary |
| 担当CA | ドロップダウン | assigned_ca |
| 登録日 | 日付 | registration_date |
| ステータス | ドロップダウン | applicant_status |
| 備考 | 文字列(複数行) | notes |
ステータスは「面談待ち」「案件紹介中」「選考中」「内定」「入社済」「辞退」「休止」の7段階です。
希望職種・希望勤務地・経験業界・保有スキルをチェックボックスにしているのは、後述するマッチング検索で「条件の重なり」を判定するためです。自由記述だと検索ができないので、ここは選択肢にしておくのがkintoneで検索性を確保するコツです。
求人案件アプリのフィールド構成
| フィールド名 | フィールドタイプ | フィールドコード |
|---|---|---|
| 企業名 | 文字列(1行) | company_name |
| 案件名 | 文字列(1行) | job_title |
| 職種 | ドロップダウン | job_type |
| 年収下限(万円) | 数値 | salary_min |
| 年収上限(万円) | 数値 | salary_max |
| 勤務地 | ドロップダウン | location |
| 必要スキル | チェックボックス | required_skills |
| 必要経験業界 | チェックボックス | required_industries |
| 案件詳細 | 文字列(複数行) | job_description |
| 募集人数 | 数値 | headcount |
| 紹介済み人数 | 数値 | introduced_count |
| ステータス | ドロップダウン | job_status |
| 掲載開始日 | 日付 | start_date |
| 担当営業 | ドロップダウン | sales_rep |
| 備考 | 文字列(複数行) | job_notes |
マッチング候補を自動抽出するカスタマイズ
アプリを作っただけでは「Excelがkintoneに移動しただけ」です。ここからがカスタマイズの出番です。
求職者の詳細画面を開いたとき、その求職者の希望条件に合致する求人案件を自動的にリストアップする機能を実装しました。CAが朝一でこの画面を開けば、「今日マッチングすべき求職者と案件の組み合わせ」がすぐにわかります。
(function() {
'use strict';
// 求職者の詳細画面表示時にマッチング候補を表示
kintone.events.on('app.record.detail.show', function(event) {
var record = event.record;
var spaceEl = kintone.app.record.getSpaceElement('matching_space');
if (!spaceEl) return event;
// 既にマッチング候補が表示されていれば再描画しない
if (spaceEl.querySelector('#matching-candidates')) return event;
// 求職者の希望条件を取得
var desiredJobTypes = record.desired_job_types.value.map(function(v) { return v; });
var desiredSalary = Number(record.desired_salary.value) || 0;
var desiredLocations = record.desired_locations.value.map(function(v) { return v; });
var skills = record.skills.value.map(function(v) { return v; });
// 求人案件アプリからマッチする案件を検索
var JOB_APP_ID = 87; // 求人案件アプリのID(環境に合わせて変更)
// クエリ構築:職種が一致 AND 年収帯が希望以上 AND 募集中
var conditions = [];
if (desiredJobTypes.length > 0) {
var jobTypeConditions = desiredJobTypes.map(function(jt) {
return 'job_type in ("' + jt + '")';
});
conditions.push('(' + jobTypeConditions.join(' or ') + ')');
}
if (desiredSalary > 0) {
conditions.push('salary_max >= ' + desiredSalary);
}
conditions.push('job_status in ("募集中")');
var query = conditions.join(' and ') + ' order by salary_max desc limit 20';
var params = {
app: JOB_APP_ID,
query: query,
fields: ['company_name', 'job_title', 'job_type', 'salary_min',
'salary_max', 'location', 'required_skills', '$id']
};
kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', params)
.then(function(resp) {
var candidates = resp.records;
// マッチ度スコアを計算(勤務地一致 +2、スキル一致 +1/件)
candidates.forEach(function(job) {
var score = 0;
var jobLocation = job.location.value;
if (desiredLocations.indexOf(jobLocation) >= 0) {
score += 2;
}
var requiredSkills = job.required_skills.value.map(function(v) { return v; });
requiredSkills.forEach(function(rs) {
if (skills.indexOf(rs) >= 0) {
score += 1;
}
});
job._matchScore = score;
});
// スコア順にソート
candidates.sort(function(a, b) { return b._matchScore - a._matchScore; });
// マッチング候補一覧を描画
var container = document.createElement('div');
container.id = 'matching-candidates';
container.style.cssText = 'margin-top:8px;';
var title = document.createElement('h3');
title.textContent = 'マッチング候補(' + candidates.length + '件)';
title.style.cssText = 'font-size:16px;font-weight:bold;margin:0 0 12px;color:#333;';
container.appendChild(title);
if (candidates.length === 0) {
var noResult = document.createElement('p');
noResult.textContent = '条件に合致する求人案件が見つかりませんでした。';
noResult.style.cssText = 'color:#888;font-size:14px;';
container.appendChild(noResult);
} else {
var table = document.createElement('table');
table.style.cssText = 'width:100%;border-collapse:collapse;font-size:14px;';
var thead = document.createElement('thead');
thead.innerHTML =
'' +
'マッチ度' +
'企業名' +
'案件名' +
'職種' +
'年収帯' +
'勤務地' +
'';
table.appendChild(thead);
var tbody = document.createElement('tbody');
candidates.forEach(function(job) {
var score = job._matchScore;
var stars = '';
for (var i = 0; i < Math.min(score, 5); i++) { stars += '★'; }
if (score === 0) stars = '−';
var salaryText = job.salary_min.value + '〜' + job.salary_max.value + '万円';
var tr = document.createElement('tr');
tr.style.cssText = 'border-bottom:1px solid #eee;cursor:pointer;';
var cellData = [
{ text: stars, style: 'padding:8px 12px;color:#f5a623;' },
{ text: job.company_name.value, style: 'padding:8px 12px;' },
{ text: job.job_title.value, style: 'padding:8px 12px;' },
{ text: job.job_type.value, style: 'padding:8px 12px;' },
{ text: salaryText, style: 'padding:8px 12px;text-align:right;' },
{ text: job.location.value, style: 'padding:8px 12px;' }
];
cellData.forEach(function(d) {
var td = document.createElement('td');
td.textContent = d.text;
td.style.cssText = d.style;
tr.appendChild(td);
});
// クリックで求人案件の詳細画面に遷移
var jobId = job['$id'].value;
tr.addEventListener('click', function() {
window.open('/k/' + JOB_APP_ID + '/show#record=' + jobId, '_blank');
});
tr.addEventListener('mouseenter', function() {
this.style.backgroundColor = '#f9f9f9';
});
tr.addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
}
spaceEl.appendChild(container);
})
.catch(function(err) {
console.error('マッチング候補の取得に失敗しました:', err);
});
return event;
});
})();このコードは求職者アプリの詳細画面で動きます。画面を開くと、求職者の希望職種・希望年収・希望勤務地・スキルに合致する求人案件をREST APIで検索し、マッチ度(勤務地一致 +2点、スキル一致 +1点/件)でスコアリングして表示します。
表の行をクリックすると、その求人案件の詳細画面が新しいタブで開きます。CAはこの一覧を見ながら「この案件を紹介しよう」と判断し、マッチング履歴アプリに記録を残す流れです。
ここで大事なのは、スペースフィールド(matching_space)を求職者アプリのフォームに追加しておくことです。このスペースがないとカスタマイズの表示場所がありません。kintoneのフォーム設定で「スペース」要素を配置し、要素IDにmatching_spaceを設定してください。
紹介済みチェック:重複紹介を防ぐ
マッチング候補の表示だけでは、「この案件はもう別のCAが紹介した」かどうかがわかりません。マッチング履歴アプリを参照して、既に紹介済みの案件にはラベルを付ける処理を追加します。
// マッチング候補表示後、既紹介チェックを追加する関数
function checkAlreadyIntroduced(applicantName, candidates, tbody, MATCHING_APP_ID) {
var query = 'applicant_name = "' + applicantName + '"';
var params = {
app: MATCHING_APP_ID,
query: query,
fields: ['job_id', 'result']
};
kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', params)
.then(function(resp) {
var introduced = {};
resp.records.forEach(function(r) {
introduced[r.job_id.value] = r.result.value;
});
var rows = tbody.querySelectorAll('tr');
candidates.forEach(function(job, i) {
var jobId = job['$id'].value;
if (introduced[jobId]) {
var label = document.createElement('span');
label.textContent = '紹介済(' + introduced[jobId] + ')';
label.style.cssText =
'display:inline-block;font-size:11px;padding:2px 6px;' +
'border-radius:3px;margin-left:8px;' +
'background:#ffe0e0;color:#c00;';
if (rows[i]) {
rows[i].querySelector('td:nth-child(3)').appendChild(label);
}
}
});
});
}これで、既に紹介済みの案件には赤いラベルが付きます。「紹介済(不採用)」「紹介済(辞退)」など、結果まで表示されるので、CAは一目で「この案件はもう紹介したけどダメだったんだな」とわかります。
ハマりどころ・注意点
私も最初の実装でいくつかハマったポイントがあるので共有します。
チェックボックスの値は配列で返る
kintoneのチェックボックスフィールドは、値が配列で返ります。record.desired_job_types.valueは["営業", "企画"]のような形です。これをクエリ条件に組み立てるとき、in演算子を使うのですが、チェックボックス同士の「いずれかが一致」を表現するにはorで繋ぐ必要があります。最初、andで繋いでしまって「全条件一致」になり、候補が0件になりました、、、、
REST APIの取得上限は500件
kintone REST APIの1回のリクエストで取得できるレコード数は500件です。求人案件が500件を超える場合は、オフセットを使ったページネーション処理が必要です。今回はlimit 20で上位20件だけ取得しているので問題ありませんが、全件取得したい場合は再帰的にAPIを呼ぶ処理を書く必要があります。
マッチ度スコアは「運用しながら調整」がベスト
最初のスコアリングロジック(勤務地一致 +2、スキル一致 +1)は、あくまで出発点です。お客様と相談して「業界経験の一致は +3にしたい」「年収差が100万円以内なら +1」など、運用しながら調整していきました。スコアリングの重みは定数にしておくと、後から変更しやすくなります。
導入結果
お客様にこの仕組みをお見せしたら、「CAが朝一でやることが明確になった」と言っていただけました。
導入前後の変化をまとめると以下のとおりです。
- マッチング作業時間:1人あたり1日60分 → 15分(候補一覧を見て判断するだけ)
- 重複紹介事故:月3〜4件 → 導入後3ヶ月でゼロ
- 紹介実績の集計:月末にExcelを集めて半日 → リアルタイムで確認可能
特に重複紹介の解消は、求職者からの信頼回復につながったとのことです。
まとめ
今回は、人材紹介会社のマッチング業務をkintoneで仕組み化した事例を紹介しました。ポイントは以下のとおりです。
- 求職者・求人案件・マッチング履歴の3アプリ構成で「誰に何を紹介したか」を共有する
- 求職者の詳細画面にマッチング候補を自動表示し、CAの判断をサポートする
- 紹介済みチェックで重複紹介を防止する
- マッチ度スコアは運用しながらお客様と一緒に調整する
人材紹介会社のマッチング業務は、担当者の経験と勘に頼りがちですが、kintoneで「検索可能な仕組み」にすることで、属人化を解消できます。もちろん最終判断はCAの経験が活きる部分です。kintoneはあくまで「候補を揃えてくれるアシスタント」という位置づけです。
株式会社ファストコーディングでは、kintoneのプラグイン開発やカスタマイズ、外部システム連携のご相談を承っています。「うちの業務に合ったkintoneの使い方を知りたい」「マッチング業務を効率化したい」という方は、お問い合わせフォームからお気軽にご連絡ください。

