【Pleasanter】1on1面談からフォローアップ課題を自動登録する

面談の記録をExcelで作っていると、前回の話や、その後どうなったかを探し直すことになる。フォローが必要な内容を別の課題表へ転記する運用も、漏れや重複が起きやすい。

そこで、面談結果と次回へのフォローアップをプリザンターで管理する構成を考えた。閲覧するのは面談担当リーダーと管理担当者だけ。メンバー本人にはシステム上の閲覧権限を与えない前提だ。

注意:掲載するサイトパッケージは公開仕様とエクスポート例に合わせて作成したテンプレート。実際のPleasanterからエクスポートしたものではなく、対象バージョンへの実機インポート試験も未実施。テスト環境で動作確認し、権限を設定するまで実データを投入しないこと。

サンプルデータについて:記事内の組織名、グループ名、ユーザー名、ID、URL、ファイルパスはすべて架空の例です。実在する組織の内部名称や実環境の接続情報は含みません。example.invalid は例示専用の予約ドメインです。

全体構成

                   ┌────────────────────┐
                   │  1on1面談管理     │
                   │  (フォルダ)      │
                   └──────────┬─────────┘
                              │
             ┌────────────────┼────────────────┐
             ▼                ▼                ▼
       ┌──────────┐    ┌────────────┐    ┌────────────┐
       │面談対象者台帳│    │1on1面談記録 │    │フォロー課題 │
       │1人1レコード│───▶│1面談1レコード│───▶│1課題1レコード│
       └──────────┘    └────────────┘    └────────────┘
サイト テーブル種別 役割
面談対象者台帳 記録テーブル 氏名、所属、役割、育成方針、面談リーダー
1on1面談記録 記録テーブル 面談内容、本人の希望、リーダー所見、上位確認
フォローアップ課題 期限付きテーブル 誰が・いつまでに・何をするか

上の矢印はテーブル間の参照関係を表している。実装では面談記録の ClassA が面談対象者台帳を参照し、フォローアップ課題の ClassA が面談記録を参照する。

操作の流れ

1on1面談を保存
      │
      ├─ フォローアップ不要 ──▶ 面談記録だけを保存
      │
      └─ フォローアップ必要
              │
              ▼
      同じ面談を参照する課題があるか確認
              │
         ┌────┴────┐
         │         │
       なし        あり
         │         │
         ▼         ▼
   課題を1件作成   何もしない

承認や上位確認のステータス遷移は設けない。面談担当者が記録を保存した時点で、CheckA(フォローアップ必要)がオンならサーバスクリプトが動く。関連面談IDで既存課題を検索してから作成するため、同じ面談を再保存しても課題は重複しない。

記録に持たせる項目

面談対象者台帳

Pleasanterの項目名 表示名
Title ユーザ名
ClassA、ClassB 所属チーム、役割
Owner 面談担当リーダー
ClassC 在籍状況
DescriptionA、DescriptionB 育成目標、担当業務
DateA、CheckA 面談開始日、面談対象

1on1面談記録

Pleasanterの項目名 表示名
Title / ClassA / DateA 面談件名 / 面談対象者台帳のユーザ名 / 面談日
Owner 面談リーダー
ClassB / ClassC 面談区分 / 本人申告の業務状況
DescriptionA 現在の業務状況
DescriptionB 困っていること・課題
DescriptionC 本人の意向・希望
DescriptionD リーダー所見
DescriptionE 双方で合意した対応事項
DescriptionF 次回確認事項
DateB 次回面談予定日
CheckA フォローアップ必要

ClassA は面談対象者台帳(サンプルサイトID 9700001)への参照項目であり、自由入力ではない。候補には台帳の Title(表示名:ユーザ名)が表示される。

本人が話した内容と、リーダー側の見解は別の欄にする。 読み返したときに、誰の認識だったのかが分からなくなることを防ぎたい。

フォローアップ課題

Pleasanterの項目名 表示名
Title 課題・対応事項
ClassA / ClassB 関連面談記録 / 対象メンバー
Owner / ClassC 担当リーダー / 対応主体
StartTime / CompletionTime 対応開始日 / 対応期限
Status / ProgressRate 対応状況 / 進捗
Body / DescriptionA 対応内容 / 結果

ここでは標準の CompletionTime を 対応期限 として使っている。実際の完了日を追跡したい場合は別項目を追加する。

サイトパッケージの取り込み

  1. 検証用のPleasanter環境に、サイト管理権限を持つユーザーでログイン。
  2. [管理] → [サイトパッケージのインポート] を開く。
  3. site-package.json を指定し、サイト・レコード・項目のアクセス制御、通知、リマインダーの取り込みは OFF にする。
  4. 新規作成されたフォルダと3テーブル、テーブル間リンクを確認する。
  5. 次節のアクセス制御を設定し、同梱されたサーバスクリプトが有効であることを確認するまでは、テスト用の架空データだけを入力する。

サイトパッケージは新しいサイトを作る機能であり、既存サイトの上書きには使えない。トップのフォルダが継承する権限にも注意が必要だ。

最優先:アクセス制御

面談は機密情報を含む。今回はインポート先のユーザーIDやグループIDが分からないため、サイトパッケージに権限設定を埋め込んでいない。導入先で設定する必要がある。

利用者 面談対象者台帳 面談記録 フォロー課題
SAMPLE_1ON1_LEADER 担当分のみ読取 作成+担当記録の読取・更新 作成+担当分の読取・更新
SAMPLE_1ON1_ADMIN 管理 管理 管理
一般メンバー 権限なし 権限なし 権限なし

設定の順番

  1. グループを作成してリーダーと管理担当者を登録。
  2. ルートフォルダと3テーブルのアクセス権の継承を確認して、不要な継承を解除。
  3. リーダー全員にはサイト全体の読取権限を付けない。サイト「作成」と担当レコードへの「読取・更新」を分けて設定する。
  4. レコードのアクセス制御を設定し、担当リーダーに権限が自動付与されることを確認。
  5. 一般メンバーには面談記録とフォローアップ課題の権限を付けない。
  6. サーバスクリプトは操作ユーザーの権限で動くため、面談を保存するリーダーにフォローアップ課題テーブルの作成権限を付ける。
  7. リーダーA・リーダーB・管理担当者・一般メンバーで、直接URLを含むアクセスを検証する。

フォローアップ課題の自動登録

サイトパッケージの面談記録テーブルには、次のサーバスクリプトを ServerScripts として同梱している。

  • タイトル:フォローアップ課題を自動作成
  • 名称:create_follow_up_issue
  • 条件:作成後 と 更新後
  • 無効:オフ
  • FOLLOW_UP_SITE_ID:サイトパッケージのID変換マーカーにより、インポート先の「フォローアップ課題」テーブルのサイトIDへ自動変換

インポート後、面談記録テーブルの[管理]→[テーブルの管理]→[サーバスクリプト]を開き、スクリプトが有効な状態で登録され、作成後・更新後にチェックが付いていることを確認する

// 面談記録の作成後・更新後に実行する。
// CheckA がオンで、同じ面談を参照する課題が存在しない場合だけ1件作成する。
// @siteid list start@
const FOLLOW_UP_SITE_ID = 9700003;
// @siteid list end@
const meetingId = String(model.ResultId);

if (!model.CheckA) {
    return;
}

const view = {
    View: {
        ColumnFilterHash: {
            ClassA: JSON.stringify([meetingId])
        }
    }
};

const existing = Array.from(
    items.Get(FOLLOW_UP_SITE_ID, JSON.stringify(view))
);

if (existing.length > 0) {
    return;
}

const followUp = items.NewIssue();
followUp.Title = model.Title + ' のフォローアップ';
followUp.ClassA = meetingId;
followUp.ClassB = String(model.ClassA);
followUp.Owner = model.Owner;
followUp.ClassC = 'リーダー';
followUp.StartTime = model.DateA;
followUp.Body = model.DescriptionE || model.DescriptionF || '対応内容を入力してください。';
followUp.Status = 100;

if (!items.Create(FOLLOW_UP_SITE_ID, followUp)) {
    context.Log('フォローアップ課題の作成に失敗しました。面談ID=' + meetingId);
}

この処理は課題の自動作成だけを行う。CheckA を後からオフにしても、既に作った課題は自動削除しない。また、面談内容を更新しても既存課題へは上書きしない。履歴保全と意図しない削除を避けるためである。

サーバスクリプトの items.Create と items.Get は実行ユーザーのアクセス権に従う。権限不足の場合は作成できないため、運用開始前にリーダー権限で必ずテストする。

テスト項目

  • 面談対象者を開くと、面談対象者台帳の ユーザ名 が候補として表示され、自由入力になっていない。
  • フォローアップ必要 をオフにした面談では課題が作られない。
  • フォローアップ必要 をオンにした面談では関連課題が1件作られる。
  • 同じ面談を再保存しても関連課題が増えない。
  • 作成された課題の関連面談、対象メンバー、担当リーダー、対応内容が引き継がれる。
  • リーダーAがリーダーBの記録を読めない。
  • 一般ユーザーが面談記録と課題を開けない。
  • CSVインポートやAPI更新でも想定どおり動くか、使用する更新経路ごとに確認する。
  • 保管期間、バックアップ、異動時の権限剥奪が決まっている。

JSONの静的検証

Python 3.10以上で次を実行する。

python validate-package.py

この検査は形式と意図した状態遷移をチェックするもので、実機インポートの成功を保証しない。

サイトパッケージ JSON全文

ファイル:site-package.json(省略なし)

{
  "HeaderInfo": {
    "AssemblyVersion": "1.5.8.1",
    "BaseSiteId": 9700000,
    "Server": "https://pleasanter.example.invalid",
    "CreatorName": "Pleasanter 1on1 sample generator (fictional data)",
    "PackageTime": "2026-10-22T00:00:00+09:00",
    "Convertors": [
      {
        "SiteId": 9700000,
        "SiteTitle": "1on1面談管理",
        "ReferenceType": "Sites",
        "IncludeData": false,
        "Order": "[9700001,9700002,9700003]"
      },
      {
        "SiteId": 9700001,
        "SiteTitle": "面談対象者台帳",
        "ReferenceType": "Results",
        "IncludeData": false
      },
      {
        "SiteId": 9700002,
        "SiteTitle": "1on1面談記録",
        "ReferenceType": "Results",
        "IncludeData": false
      },
      {
        "SiteId": 9700003,
        "SiteTitle": "フォローアップ課題",
        "ReferenceType": "Issues",
        "IncludeData": false
      }
    ],
    "IncludeSitePermission": false,
    "IncludeRecordPermission": false,
    "IncludeColumnPermission": false,
    "IncludeNotifications": false,
    "IncludeReminders": false
  },
  "Sites": [
    {
      "TenantId": 1,
      "SiteId": 9700000,
      "Title": "1on1面談管理",
      "SiteName": "oneonone_9700000",
      "SiteGroupName": "",
      "Body": "",
      "GridGuide": "",
      "EditorGuide": "",
      "CalendarGuide": "",
      "CrosstabGuide": "",
      "GanttGuide": "",
      "BurnDownGuide": "",
      "TimeSeriesGuide": "",
      "KambanGuide": "",
      "ImageLibGuide": "",
      "ReferenceType": "Sites",
      "ParentId": 0,
      "InheritPermission": 9700000,
      "SiteSettings": {
        "Version": 1.017,
        "ReferenceType": "Sites",
        "NoDisplayIfReadOnly": false
      },
      "Publish": false,
      "DisableCrossSearch": true,
      "Comments": []
    },
    {
      "TenantId": 1,
      "SiteId": 9700001,
      "Title": "面談対象者台帳",
      "SiteName": "oneonone_9700001",
      "SiteGroupName": "",
      "Body": "",
      "GridGuide": "",
      "EditorGuide": "",
      "CalendarGuide": "",
      "CrosstabGuide": "",
      "GanttGuide": "",
      "BurnDownGuide": "",
      "TimeSeriesGuide": "",
      "KambanGuide": "",
      "ImageLibGuide": "",
      "ReferenceType": "Results",
      "ParentId": 9700000,
      "InheritPermission": 9700001,
      "SiteSettings": {
        "Version": 1.017,
        "ReferenceType": "Results",
        "GridColumns": [
          "ResultId",
          "Title",
          "ClassA",
          "ClassB",
          "Owner",
          "ClassC",
          "CheckA"
        ],
        "EditorColumnHash": {
          "General": [
            "ResultId",
            "Title",
            "ClassA",
            "ClassB",
            "Owner",
            "ClassC",
            "DescriptionA",
            "DescriptionB",
            "DateA",
            "CheckA"
          ]
        },
        "Columns": [
          {
            "ColumnName": "Title",
            "LabelText": "ユーザ名",
            "ValidateRequired": true
          },
          {
            "ColumnName": "ClassA",
            "LabelText": "所属チーム",
            "ChoicesText": "サンプルチームA\nサンプルチームB\nその他"
          },
          {
            "ColumnName": "ClassB",
            "LabelText": "役割",
            "ChoicesText": "新人\n一般\nサブリーダー\nリーダー"
          },
          {
            "ColumnName": "Owner",
            "LabelText": "面談担当リーダー"
          },
          {
            "ColumnName": "ClassC",
            "LabelText": "在籍状況",
            "ChoicesText": "在籍\n異動\n退職"
          },
          {
            "ColumnName": "DescriptionA",
            "LabelText": "育成目標"
          },
          {
            "ColumnName": "DescriptionB",
            "LabelText": "担当業務"
          },
          {
            "ColumnName": "DateA",
            "LabelText": "面談開始日",
            "GridFormat": "Ymd"
          },
          {
            "ColumnName": "CheckA",
            "LabelText": "面談対象"
          }
        ],
        "ImportEncoding": "UTF-8",
        "NoDisplayIfReadOnly": false
      },
      "Publish": false,
      "DisableCrossSearch": true,
      "Comments": []
    },
    {
      "TenantId": 1,
      "SiteId": 9700002,
      "Title": "1on1面談記録",
      "SiteName": "oneonone_9700002",
      "SiteGroupName": "",
      "Body": "",
      "GridGuide": "",
      "EditorGuide": "",
      "CalendarGuide": "",
      "CrosstabGuide": "",
      "GanttGuide": "",
      "BurnDownGuide": "",
      "TimeSeriesGuide": "",
      "KambanGuide": "",
      "ImageLibGuide": "",
      "ReferenceType": "Results",
      "ParentId": 9700000,
      "InheritPermission": 9700002,
      "SiteSettings": {
        "Version": 1.017,
        "ReferenceType": "Results",
        "GridColumns": [
          "ResultId",
          "Title",
          "ClassA",
          "DateA",
          "Owner",
          "ClassB",
          "CheckA"
        ],
        "EditorColumnHash": {
          "General": [
            "ResultId",
            "Title",
            "ClassA",
            "DateA",
            "Owner",
            "ClassB",
            "ClassC",
            "DescriptionA",
            "DescriptionB",
            "DescriptionC",
            "DescriptionD",
            "DescriptionE",
            "DescriptionF",
            "DateB",
            "CheckA"
          ]
        },
        "Columns": [
          {
            "ColumnName": "Title",
            "LabelText": "面談件名",
            "ValidateRequired": true
          },
          {
            "ColumnName": "ClassA",
            "LabelText": "面談対象者",
            "ChoicesText": "[[9700001]]",
            "ValidateRequired": true
          },
          {
            "ColumnName": "DateA",
            "LabelText": "面談実施日",
            "GridFormat": "Ymd",
            "ValidateRequired": true
          },
          {
            "ColumnName": "Owner",
            "LabelText": "面談リーダー",
            "ValidateRequired": true
          },
          {
            "ColumnName": "ClassB",
            "LabelText": "面談区分",
            "ChoicesText": "定期面談\n臨時面談\n育成面談\nキャリア面談"
          },
          {
            "ColumnName": "ClassC",
            "LabelText": "本人申告の業務状況",
            "ChoicesText": "順調\n普通\n支援希望"
          },
          {
            "ColumnName": "DescriptionA",
            "LabelText": "現在の業務状況"
          },
          {
            "ColumnName": "DescriptionB",
            "LabelText": "困っていること・課題"
          },
          {
            "ColumnName": "DescriptionC",
            "LabelText": "本人の意向・希望"
          },
          {
            "ColumnName": "DescriptionD",
            "LabelText": "リーダー所見"
          },
          {
            "ColumnName": "DescriptionE",
            "LabelText": "双方で合意した対応事項"
          },
          {
            "ColumnName": "DescriptionF",
            "LabelText": "次回確認事項"
          },
          {
            "ColumnName": "DateB",
            "LabelText": "次回面談予定日",
            "GridFormat": "Ymd"
          },
          {
            "ColumnName": "CheckA",
            "LabelText": "フォローアップ必要"
          }
        ],
        "PermissionForCreating": {
          "Owner": 5
        },
        "PermissionForUpdating": {
          "Owner": 5
        },
        "ImportEncoding": "UTF-8",
        "NoDisplayIfReadOnly": false,
        "ServerScripts": [
          {
            "Id": 1,
            "Title": "フォローアップ課題を自動作成",
            "Name": "create_follow_up_issue",
            "AfterCreate": true,
            "AfterUpdate": true,
            "Body": "// 面談記録の作成後・更新後に実行する。\n// CheckA がオンで、同じ面談を参照する課題が存在しない場合だけ1件作成する。\n// @siteid list start@\nconst FOLLOW_UP_SITE_ID = 9700003;\n// @siteid list end@\nconst meetingId = String(model.ResultId);\n\nif (!model.CheckA) {\n    return;\n}\n\nconst view = {\n    View: {\n        ColumnFilterHash: {\n            ClassA: JSON.stringify([meetingId])\n        }\n    }\n};\n\nconst existing = Array.from(\n    items.Get(FOLLOW_UP_SITE_ID, JSON.stringify(view))\n);\n\nif (existing.length > 0) {\n    return;\n}\n\nconst followUp = items.NewIssue();\nfollowUp.Title = model.Title + ' のフォローアップ';\nfollowUp.ClassA = meetingId;\nfollowUp.ClassB = String(model.ClassA);\nfollowUp.Owner = model.Owner;\nfollowUp.ClassC = 'リーダー';\nfollowUp.StartTime = model.DateA;\nfollowUp.Body = model.DescriptionE || model.DescriptionF || '対応内容を入力してください。';\nfollowUp.Status = 100;\n\nif (!items.Create(FOLLOW_UP_SITE_ID, followUp)) {\n    context.Log('フォローアップ課題の作成に失敗しました。面談ID=' + meetingId);\n}"
          }
        ],
        "ServerScriptsAllDisabled": false
      },
      "Publish": false,
      "DisableCrossSearch": true,
      "Comments": []
    },
    {
      "TenantId": 1,
      "SiteId": 9700003,
      "Title": "フォローアップ課題",
      "SiteName": "oneonone_9700003",
      "SiteGroupName": "",
      "Body": "",
      "GridGuide": "",
      "EditorGuide": "",
      "CalendarGuide": "",
      "CrosstabGuide": "",
      "GanttGuide": "",
      "BurnDownGuide": "",
      "TimeSeriesGuide": "",
      "KambanGuide": "",
      "ImageLibGuide": "",
      "ReferenceType": "Issues",
      "ParentId": 9700000,
      "InheritPermission": 9700003,
      "SiteSettings": {
        "Version": 1.017,
        "ReferenceType": "Issues",
        "GridColumns": [
          "IssueId",
          "Title",
          "ClassA",
          "ClassB",
          "Owner",
          "Status",
          "StartTime",
          "CompletionTime",
          "ProgressRate"
        ],
        "EditorColumnHash": {
          "General": [
            "IssueId",
            "Title",
            "ClassA",
            "ClassB",
            "Owner",
            "ClassC",
            "StartTime",
            "CompletionTime",
            "Status",
            "ProgressRate",
            "Body",
            "DescriptionA"
          ]
        },
        "Columns": [
          {
            "ColumnName": "Title",
            "LabelText": "課題・対応事項",
            "ValidateRequired": true
          },
          {
            "ColumnName": "ClassA",
            "LabelText": "関連する面談記録",
            "ChoicesText": "[[9700002]]"
          },
          {
            "ColumnName": "ClassB",
            "LabelText": "対象メンバー",
            "ChoicesText": "[[9700001]]"
          },
          {
            "ColumnName": "Owner",
            "LabelText": "担当リーダー"
          },
          {
            "ColumnName": "ClassC",
            "LabelText": "対応主体",
            "ChoicesText": "本人\nリーダー\nその他"
          },
          {
            "ColumnName": "StartTime",
            "LabelText": "対応開始日",
            "GridFormat": "Ymd",
            "EditorFormat": "Ymd"
          },
          {
            "ColumnName": "CompletionTime",
            "LabelText": "対応期限",
            "GridFormat": "Ymd",
            "EditorFormat": "Ymd"
          },
          {
            "ColumnName": "Body",
            "LabelText": "対応内容"
          },
          {
            "ColumnName": "DescriptionA",
            "LabelText": "結果・確認事項"
          },
          {
            "ColumnName": "Status",
            "LabelText": "対応状況",
            "ChoicesText": "100,未着手\n200,対応中\n900,完了",
            "DefaultValue": "100"
          }
        ],
        "PermissionForCreating": {
          "Owner": 5
        },
        "PermissionForUpdating": {
          "Owner": 5
        },
        "ImportEncoding": "UTF-8",
        "NoDisplayIfReadOnly": false
      },
      "Publish": false,
      "DisableCrossSearch": true,
      "Comments": []
    }
  ],
  "Data": [],
  "Permissions": [],
  "PermissionIdList": {
    "DeptIdList": [],
    "GroupIdList": [],
    "UserIdList": []
  }
}

検証スクリプト全文

ファイル:validate-package.py(省略なし)

#!/usr/bin/env python3
# UTF-8 / Python 3.10+ / 追加依存なし
import json
import sys
from pathlib import Path

EXPECTED = {"Sites": 1, "Results": 2, "Issues": 1}
REMOVED_MEETING_COLUMNS = {
    "Manager", "DescriptionG", "DateC", "DateD", "ClassD"
}

def main():
    path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("site-package.json")
    data = json.loads(path.read_text(encoding="utf-8-sig"))
    assert data["HeaderInfo"]["IncludeSitePermission"] is False, "権限を同梱しない方針"
    assert data["Data"] == [], "実データを含めないこと"
    ids = [site["SiteId"] for site in data["Sites"]]
    assert len(ids) == 4 and len(set(ids)) == 4, "サイトIDの重複"
    for name, count in EXPECTED.items():
        assert sum(s["ReferenceType"] == name for s in data["Sites"]) == count, name

    meetings = next(s for s in data["Sites"] if s["Title"] == "1on1面談記録")
    conf = meetings["SiteSettings"]
    assert not conf.get("Processes"), "承認プロセスを含めないこと"
    scripts = conf.get("ServerScripts", [])
    assert len(scripts) == 1, "サーバスクリプトを1件含めること"
    script = scripts[0]
    assert script.get("AfterCreate") is True, "作成後に実行すること"
    assert script.get("AfterUpdate") is True, "更新後に実行すること"
    assert "items.Create" in script.get("Body", ""), "課題作成処理が必要"
    assert "// @siteid list start@" in script.get("Body", ""), "サイトID変換開始マーカーが必要"
    assert "// @siteid list end@" in script.get("Body", ""), "サイトID変換終了マーカーが必要"
    names = {c["ColumnName"] for c in conf.get("Columns", [])}
    assert "CheckA" in names, "フォローアップ必要項目が必要"
    assert not (names & REMOVED_MEETING_COLUMNS), "上位確認用項目を含めないこと"

    for site in data["Sites"]:
        column_names = [c["ColumnName"] for c in site["SiteSettings"].get("Columns", [])]
        assert len(column_names) == len(set(column_names)), "項目名重複: " + site["Title"]

    print("PASS: JSON構造・テーブル・サーバスクリプト・上位確認項目の除外(静的検査)")
    print("NOTE: サーバスクリプト、実機インポート、権限、動作は実環境で要検証")

if __name__ == "__main__":
    main()

参考

Previous Post Next Post