kintone
投稿日:

「マッチングの記録、担当者の頭の中にありませんか?」── kintoneで人材紹介・派遣会社の案件管理を一元化する

皆さんこんにちは。kintoneアプリエンジニアのtomiokaです。

先日、営業10名ほどの人材紹介会社の管理部門の方から「誰がどの求職者にどの案件を紹介したか、共有できていない」と相談がありました。

求職者の情報と求人案件がそれぞれ別のExcelで管理されていて、紹介履歴はCAの個人メモにしか残っていない状態だそうです。ある求職者に別のCAが同じ企業を紹介してしまい、クライアント企業から「御社は情報共有できていないの?」と指摘を受けたことが相談のきっかけでした。

この記事は、営業5〜30名規模の中小人材紹介・人材派遣会社で、求職者情報や紹介履歴がExcelと担当者の個人メモに散在している管理部門やCA(キャリアアドバイザー)の方に向けて書いています。

Excelと個人メモの管理で何が起きていたか

詳しく聞いてみると、問題は「重複紹介」だけではありませんでした。

まず、マッチングの属人化です。求職者の希望条件と求人案件の要件を突き合わせる作業は、CAの頭の中で行われていました。ベテランCAなら経験と記憶でうまくマッチングできますが、新人が入ると「どの案件を誰に紹介すればいいかわからない」となる。引き継ぎもメモベースなので、前任の判断基準が見えません。

次に、派遣スタッフの契約管理です。人材派遣の場合、同一の派遣先で働ける期間には上限があります。この抵触日をExcelで管理していたのですが、ファイルの更新が漏れていて、抵触日の1週間前になって気づくケースがあったそうです。「慌ててクライアントに連絡して、ギリギリで別のスタッフを手配した」と。

そして、実績の見える化です。CAごとの紹介件数や成約率を月末に集計していましたが、これも手作業。数字が出るのは翌月の第1週で、タイムリーなマネジメントができていませんでした。

kintoneで構築した3つのアプリ

今回の仕組みは、3つのアプリで構成しています。

求職者マスタアプリ

求職者の基本情報と希望条件を管理するアプリです。

フィールド名フィールドタイプ用途
求職者ID文字列(1行)一意の識別子
氏名文字列(1行)求職者のフルネーム
年齢数値年齢
希望職種ドロップダウン営業/事務/エンジニア/マーケティング/その他
希望勤務地ドロップダウン東京/神奈川/大阪/愛知/その他
希望年収数値万円単位
担当CA文字列(1行)担当キャリアアドバイザー名
ステータスドロップダウン登録済/紹介中/内定/入社済/辞退
登録日日付登録した日

ポイントは、希望条件をフィールドとして構造化している点です。Excelだと「備考欄に自由記述」になりがちですが、ドロップダウンや数値で持たせることで、後述のマッチング検索が可能になります。

求人案件アプリ

クライアント企業から受けた求人案件を管理するアプリです。

フィールド名フィールドタイプ用途
案件ID文字列(1行)一意の識別子
企業名文字列(1行)クライアント企業名
職種ドロップダウン営業/事務/エンジニア/マーケティング/その他
勤務地ドロップダウン東京/神奈川/大阪/愛知/その他
年収下限数値万円単位
年収上限数値万円単位
雇用形態ドロップダウン正社員/契約社員/派遣
案件ステータスドロップダウン募集中/充足/クローズ
担当営業文字列(1行)案件の営業担当者名

紹介履歴アプリ

どのCAが、どの求職者に、どの案件を紹介したかの記録を管理するアプリです。

フィールド名フィールドタイプ用途
紹介日日付紹介した日
求職者IDルックアップ求職者マスタから取得
求職者名文字列(1行)ルックアップで自動コピー
案件IDルックアップ求人案件から取得
企業名文字列(1行)ルックアップで自動コピー
担当CA文字列(1行)紹介を行ったCA名
紹介ステータスドロップダウン紹介済/書類選考中/面接調整中/面接済/内定/入社/辞退
契約開始日日付派遣の場合の契約開始日
契約終了日日付派遣の場合の契約終了日
備考文字列(複数行)自由記入

紹介履歴を独立したアプリにしているのは、「ある求職者に対して複数の案件を紹介する」「ある案件に対して複数の求職者を紹介する」という多対多の関係を管理するためです。

条件に合う求職者をワンクリックで抽出する

求人案件の詳細画面に「マッチング候補を検索」ボタンを設置し、案件の職種・勤務地・年収条件に合致する求職者を自動で抽出するカスタマイズです。

(function() {
  'use strict';

  var JOBSEEKER_APP_ID = 86; // 求職者マスタアプリのID(実環境に合わせて変更)

  kintone.events.on('app.record.detail.show', function(event) {
    if (document.getElementById('matching-btn')) {
      return event;
    }

    var headerSpace = kintone.app.record.getHeaderMenuSpaceElement();
    if (!headerSpace) {
      return event;
    }

    var btn = document.createElement('button');
    btn.id = 'matching-btn';
    btn.textContent = 'マッチング候補を検索';
    btn.style.cssText = 'padding: 8px 16px; background: #3498db; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;';
    headerSpace.appendChild(btn);

    btn.addEventListener('click', function() {
      var record = kintone.app.record.get().record;
      var jobType = record['職種'].value;
      var location = record['勤務地'].value;
      var salaryMin = Number(record['年収下限'].value) || 0;
      var salaryMax = Number(record['年収上限'].value) || 9999;

      // 求職者マスタから条件に合うレコードを検索
      var conditions = [];
      conditions.push('ステータス in ("登録済")');
      if (jobType) {
        conditions.push('希望職種 in ("' + jobType + '")');
      }
      if (location) {
        conditions.push('希望勤務地 in ("' + location + '")');
      }

      var query = conditions.join(' and ') + ' order by 登録日 desc';
      var body = {
        app: JOBSEEKER_APP_ID,
        query: query,
        fields: ['求職者ID', '氏名', '年齢', '希望職種', '希望勤務地', '希望年収', '担当CA']
      };

      kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', body)
        .then(function(resp) {
          var records = resp.records;

          // 年収条件でフィルタリング
          var matched = [];
          for (var i = 0; i < records.length; i++) {
            var desiredSalary = Number(records[i]['希望年収'].value) || 0;
            if (desiredSalary >= salaryMin && desiredSalary <= salaryMax) {
              matched.push(records[i]);
            }
          }

          if (matched.length === 0) {
            alert('条件に合う求職者が見つかりませんでした。');
            return;
          }

          var message = '【マッチング候補】' + matched.length + '名\n\n';
          for (var j = 0; j < matched.length; j++) {
            var m = matched[j];
            message += m['求職者ID'].value + ' ' + m['氏名'].value;
            message += '(' + m['年齢'].value + '歳)';
            message += ' 希望年収:' + m['希望年収'].value + '万円';
            message += ' 担当:' + m['担当CA'].value;
            message += '\n';
          }

          alert(message);
        })
        .catch(function(error) {
          console.error(error);
          alert('検索中にエラーが発生しました。');
        });
    });

    return event;
  });
})();

処理の流れを整理すると、以下のようになります。

  • 求人案件の詳細画面(app.record.detail.show)でボタンを追加する
  • ボタンクリック時に、案件の職種・勤務地を使って求職者マスタをREST APIで検索する
  • 取得した結果を年収条件でさらにフィルタリングし、候補者をアラートで表示する

年収条件のフィルタリングをAPIのクエリではなくJavaScript側で行っているのは、kintoneのクエリでは「求職者の希望年収が案件の年収範囲内にある」という条件を直接書けないためです。職種と勤務地でまず絞り込み、残りをコード側でフィルタしています。

派遣スタッフの契約期間アラート

人材派遣の業務で特に神経を使うのが契約管理です。契約終了日が近づいているのに更新手続きが始まっていない、、、、というのは現場あるあるですよね。

このカスタマイズは紹介履歴アプリの一覧画面にボタンを設置して、契約終了日が30日以内に迫っている派遣スタッフを一覧で表示します。

(function() {
  'use strict';

  var HISTORY_APP_ID = 86; // 紹介履歴アプリのID(実環境に合わせて変更)

  kintone.events.on('app.record.index.show', function(event) {
    if (document.getElementById('contract-alert-btn')) {
      return event;
    }

    var headerSpace = kintone.app.getHeaderMenuSpaceElement();
    if (!headerSpace) {
      return event;
    }

    var btn = document.createElement('button');
    btn.id = 'contract-alert-btn';
    btn.textContent = '契約期限アラート';
    btn.style.cssText = 'padding: 8px 16px; background: #e74c3c; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;';
    headerSpace.appendChild(btn);

    btn.addEventListener('click', function() {
      var now = new Date();
      var thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
      var today = now.getFullYear() + '-' +
        ('0' + (now.getMonth() + 1)).slice(-2) + '-' +
        ('0' + now.getDate()).slice(-2);
      var limitDate = thirtyDaysLater.getFullYear() + '-' +
        ('0' + (thirtyDaysLater.getMonth() + 1)).slice(-2) + '-' +
        ('0' + thirtyDaysLater.getDate()).slice(-2);

      var query = '契約終了日 >= "' + today + '" and 契約終了日 <= "' + limitDate + '"';
      query += ' and 紹介ステータス in ("入社")';
      query += ' order by 契約終了日 asc';

      var body = {
        app: HISTORY_APP_ID,
        query: query,
        fields: ['求職者名', '企業名', '担当CA', '契約開始日', '契約終了日']
      };

      kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', body)
        .then(function(resp) {
          var records = resp.records;

          if (records.length === 0) {
            alert('30日以内に契約終了を迎えるスタッフはいません。');
            return;
          }

          var message = '【契約期限アラート】30日以内に終了: ' + records.length + '件\n\n';
          for (var i = 0; i < records.length; i++) {
            var r = records[i];
            var endDate = r['契約終了日'].value;
            var diff = Math.ceil((new Date(endDate) - now) / (1000 * 60 * 60 * 24));
            message += r['求職者名'].value + ' → ' + r['企業名'].value + '\n';
            message += '  契約終了: ' + endDate + '(あと' + diff + '日)';
            message += ' 担当: ' + r['担当CA'].value + '\n';
          }

          alert(message);
        })
        .catch(function(error) {
          console.error(error);
          alert('チェック中にエラーが発生しました。');
        });
    });

    return event;
  });
})();

ポイントは、クエリで「契約終了日が今日〜30日後の範囲」かつ「ステータスが入社(=現在稼働中)」の条件で絞り込んでいるところです。結果には契約終了日までの残日数も計算して表示するので、緊急度がひと目でわかります。

CA別の月間紹介実績を集計する

紹介履歴アプリのレコードをREST APIで取得して、CA別の今月の紹介件数・面接到達数・内定数を集計するカスタマイズです。

(function() {
  'use strict';

  var HISTORY_APP_ID = 86; // 紹介履歴アプリのID

  kintone.events.on('app.record.index.show', function(event) {
    if (document.getElementById('kpi-summary-btn')) {
      return event;
    }

    var headerSpace = kintone.app.getHeaderMenuSpaceElement();
    if (!headerSpace) {
      return event;
    }

    var btn = document.createElement('button');
    btn.id = 'kpi-summary-btn';
    btn.textContent = '月間KPI集計';
    btn.style.cssText = 'padding: 8px 16px; background: #27ae60; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; margin-left: 8px;';
    headerSpace.appendChild(btn);

    btn.addEventListener('click', function() {
      var now = new Date();
      var year = now.getFullYear();
      var month = now.getMonth() + 1;
      var startDate = year + '-' + ('0' + month).slice(-2) + '-01';
      var endYear = month === 12 ? year + 1 : year;
      var endMonth = month === 12 ? 1 : month + 1;
      var endDate = endYear + '-' + ('0' + endMonth).slice(-2) + '-01';

      var query = '紹介日 >= "' + startDate + '" and 紹介日 < "' + endDate + '"';
      var body = {
        app: HISTORY_APP_ID,
        query: query,
        fields: ['担当CA', '紹介ステータス']
      };

      kintone.api(kintone.api.url('/k/v1/records.json', true), 'GET', body)
        .then(function(resp) {
          var records = resp.records;
          var summary = {};

          for (var i = 0; i < records.length; i++) {
            var ca = records[i]['担当CA'].value;
            var status = records[i]['紹介ステータス'].value;

            if (!summary[ca]) {
              summary[ca] = { referrals: 0, interviews: 0, offers: 0 };
            }
            summary[ca].referrals++;
            if (status === '面接済' || status === '内定' || status === '入社') {
              summary[ca].interviews++;
            }
            if (status === '内定' || status === '入社') {
              summary[ca].offers++;
            }
          }

          var message = '【' + month + '月のCA別KPI】\n\n';
          var cas = Object.keys(summary).sort();
          for (var j = 0; j < cas.length; j++) {
            var s = summary[cas[j]];
            message += cas[j] + ':\n';
            message += '  紹介: ' + s.referrals + '件';
            message += '  面接: ' + s.interviews + '件';
            message += '  内定: ' + s.offers + '件';
            if (s.referrals > 0) {
              var rate = Math.round(s.offers / s.referrals * 1000) / 10;
              message += '  成約率: ' + rate + '%';
            }
            message += '\n';
          }

          alert(message);
        })
        .catch(function(error) {
          console.error(error);
          alert('集計中にエラーが発生しました。');
        });
    });

    return event;
  });
})();

このサンプルコードはレコード数が500件を超えると正しく集計できません。紹介件数が多い場合は、出欠管理の記事でも紹介した fetchAllRecords 関数のような全件取得処理を組み込んでください。

処理の流れを整理すると、以下のようになります。

  • ヘッダーメニューに「月間KPI集計」ボタンを配置する
  • クリックすると今月の紹介履歴をREST APIで取得する
  • 担当CA別にレコードを集計し、紹介件数・面接到達件数・内定件数と成約率を算出する

「面接到達」は紹介ステータスが「面接済」「内定」「入社」のいずれかの場合にカウントしています。ステータスが順に進む設計にしているので、「内定」になった求職者は当然面接を経由しているはずだからです。

アクセス権限の設計

人材紹介会社では、求職者の個人情報保護が特に重要です。

役割求職者マスタ求人案件紹介履歴
管理者閲覧・編集閲覧・編集閲覧・編集
CA自分の担当のみ閲覧・編集全件閲覧自分の担当のみ閲覧・編集
営業閲覧のみ自分の担当のみ閲覧・編集閲覧のみ

CAは自分が担当する求職者の情報だけを閲覧・編集できるようにしています。これはkintoneのレコード単位のアクセス権限で、「担当CA」フィールドとログインユーザーの一致を条件にして設定しています。

求人案件は全CAが閲覧できるようにしています。マッチング検索でどの案件にどんな候補者がいるかを確認できないと、紹介業務が回らないためです。

ハマりどころ

重複紹介のチェック

紹介履歴アプリにレコードを追加するとき、同じ求職者に同じ案件がすでに紹介されていないかチェックする必要があります。kintoneの標準機能ではレコード追加時の重複チェックができないため、app.record.create.submit イベントでREST APIを使って既存レコードを検索する方式にしました。

ただし、、、、この方式にはタイミングの問題があります。2人のCAが同時に同じ組み合わせを紹介しようとした場合、両方とも「まだ紹介されていない」と判定されてしまう可能性があります。発生頻度は低いですが、完全に防ぐには「求職者ID+案件ID」の組み合わせでユニークなフィールドを設けるなどの工夫が必要です。

紹介ステータスの遷移管理

紹介ステータスを「紹介済→書類選考中→面接調整中→面接済→内定→入社」と順番に進めることを想定していますが、実務ではステータスを戻す場面もあります。たとえば「面接済だったが不採用になり、別の案件で再紹介する」ケースです。

この場合、元の紹介履歴はそのまま残して「辞退」にし、新しい紹介として別レコードを作成する運用にしました。履歴が残るため、「この求職者に過去何件紹介したか」の追跡ができます。

年収条件のマッチング精度

年収は「下限〜上限」の範囲で管理しますが、求職者の「希望年収」は一つの数値です。このとき「希望年収400万で、案件の年収レンジが350〜500万」はマッチと判定しますが、「希望年収600万で、案件の年収レンジが350〜500万」はミスマッチです。

ただし、求職者が「年収を下げてでもやりたい仕事」を希望するケースもあるため、マッチング結果はあくまで参考値です。最終判断はCAが行う前提で、完全な自動化ではなく「候補者を絞り込む補助ツール」として位置づけています。

導入結果

導入から3か月時点での変化をまとめます。

  • 重複紹介: 重複チェックの仕組みを入れてから、重複紹介はゼロに。「クライアントからの信頼を損なうリスクがなくなった」と管理者の方に喜ばれました
  • マッチング作業: 新人CAでも「マッチング候補を検索」ボタンで候補者を絞り込めるため、案件を受けてから求職者に連絡するまでのリードタイムが平均2日から半日に短縮
  • 契約管理: 契約終了30日前のアラートにより、更新手続きの着手が早まった。「直前になって慌てることがなくなった」との声
  • KPIの可視化: 月間の紹介件数・成約率をリアルタイムで確認できるようになり、週次のチームミーティングで「今月あと何件紹介すれば目標に届くか」を具体的に議論できるように

まとめ

今回は、kintoneを使った人材紹介・派遣会社の案件管理について紹介しました。

人材紹介・派遣業務の核心は「マッチング」ですが、そのマッチングの質はデータの共有と蓄積に依存します。担当者の頭の中にしかない情報は、属人化のリスクでもあり、活用されない資産でもあります。

押さえておきたいポイントは以下の3つです。

  • 求職者・求人案件・紹介履歴を3つのアプリに分けて多対多の関係を管理し、重複紹介を防ぐ
  • 派遣契約の期限管理はアラート機能で「気づく仕組み」を作り、直前の慌ただしさをなくす
  • CA別のKPIをリアルタイムに集計できるようにし、マネジメントの判断材料にする

人材紹介・派遣業務の案件管理やkintoneの導入を検討されている方は、株式会社ファストコーディングでkintoneのカスタマイズや業務アプリの構築を承っていますので、お問い合わせフォームからお気軽にご連絡ください。