> ## Documentation Index
> Fetch the complete documentation index at: https://support.i.moneyforward.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 従業員マスターをスプレッドシートで代用する、部署単位のAdmina SaaS管理

> Adminaは従業員マスターを連携しなくても始められます。部署のディレクトリ台帳スプレッドシートを大元にして自部署の従業員とSaaSを管理する方法を、基本のCSVインポートと、反映を自動化するGASのサンプルコードで紹介します。

export const BlogAuthor = ({id}) => {
  const ICONS = "/images/blog/authors/icons";
  const FA_GLOBE = "https://d3gk2c5xim1je2.cloudfront.net/fontawesome/v7.2.0/regular/globe.svg";
  const AUTHORS = {
    howdy39: {
      name: "中野 達也（@howdy39）",
      title: "Adminaエバンジェリスト",
      avatar: "/images/blog/authors/nakano.png",
      avatarAlt: "中野 達也のプロフィール画像",
      bio: "SIer・フリーランスを経て、SaaSの使い手の体験を良くしたいとSTORESのフロントエンドエンジニアに転身。その後、社内で働く仲間の体験を良くしたいと、自ら手を挙げて情シス組織を立ち上げ、後にシニアマネージャーとして同組織を率いた。次のチャレンジとして、情シスのためのプロダクトを通じて情シスの体験を良くするため、Adminaエバンジェリストとしてマネーフォワードiに入社。",
      links: [{
        label: "X (@howdy39)",
        href: "https://x.com/howdy39",
        icon: `${ICONS}/x.svg`
      }, {
        label: "note",
        href: "https://note.com/howdy39",
        icon: `${ICONS}/note.svg`
      }, {
        label: "Qiita",
        href: "https://qiita.com/howdy39",
        icon: `${ICONS}/qiita.svg`
      }, {
        label: "Zenn",
        href: "https://zenn.dev/howdy39",
        icon: `${ICONS}/zenn.svg`
      }, {
        label: "GitHub",
        href: "https://github.com/howdy39",
        icon: `${ICONS}/github.svg`
      }, {
        label: "個人サイト",
        href: "https://howdy39.dev/",
        icon: FA_GLOBE
      }]
    }
  };
  const author = AUTHORS[id];
  if (!author) return null;
  return <div className="blog-author-card" data-author-id={id}>
      <div className="blog-author-avatar">
        <img src={author.avatar} alt={author.avatarAlt} />
      </div>
      <div className="blog-author-body">
        <div className="blog-author-name">{author.name}</div>
        <div className="blog-author-title">{author.title}</div>
        <p className="blog-author-bio">{author.bio}</p>
        <div className="blog-author-links">
          {author.links.map(link => <a key={link.href} href={link.href} aria-label={link.label}>
              <span className="blog-author-brand-icon" style={{
    WebkitMaskImage: `url(${link.icon})`,
    maskImage: `url(${link.icon})`
  }} />
            </a>)}
        </div>
      </div>
    </div>;
};

公開日: 2026 年 9 月 30 日

<Info>
  この記事は 2026 年 9 月時点の情報です。
</Info>

実はAdminaは、[従業員マスター](/it-management/tutorial/ikf8odm6ti-2)となるSaaSを設定しなくても使い始められます。「部署で契約しているSaaSのアカウント管理を任されているけれど、従業員マスターの連携はこれから」というケースもあるでしょう。そんな**部署の管理者**の方に向けて書きました。

AdminaのSaaS管理機能では、Google WorkspaceやMicrosoft Entra IDなどの「従業員マスター（全社アカウントの大元データ）」を繋いで全社導入するのが一般的な形です。その一方で、**従業員マスターSaaSは連携せず、部署のディレクトリ台帳（Google スプレッドシート）を大元データにして部署単位で運用する**形も選べます。管理する範囲は自部署の従業員とSaaSだけに絞れます。

[以前の記事](/blog/2026/spreadsheet-custom-app)では、アカウントを管理したいSaaSのうち、直接連携できないものをスプレッドシートで取り込む方法を紹介しました。この記事でスプレッドシートにするのは、従業員マスターのほうです。部署のディレクトリ台帳スプレッドシートを、Adminaの[ディレクトリ](/it-management/directory/nqxa0so8wh-admina-directory)に反映します。基本は、台帳をCSVにしてAdminaにアップロードするだけです。反映を自動化したい方向けに、GAS（Google Apps Script）とAdmina APIのサンプルコードも載せています。

<Frame caption="以前の記事とこの記事でスプレッドシートを使う場所の違い">
  <img src="https://mintcdn.com/moneyforwardi/qhvusFsxlNhqU7CD/images/blog/spreadsheet-directory/series-diff.svg?fit=max&auto=format&n=qhvusFsxlNhqU7CD&q=85&s=d38ec43cc75dc4f9d546f9853087a466" alt="以前の記事とこの記事の違いを並べた図。以前の記事は、SaaSアカウントをスプレッドシートで取り込む。ディレクトリはGoogle WorkspaceやEntra IDなどの従業員マスターと連携したままで、未対応SaaSのアカウントだけをスプレッドシート連携で取り込む。この記事は、従業員マスターをスプレッドシートにする。従業員マスターは繋がず、部署のディレクトリ台帳スプレッドシートをCSVまたはAPIでディレクトリに反映する。SaaSアカウントはOAuthやAPIなどの通常のSaaS連携で取り込む。どちらもAdminaで名寄せ・退職検知・棚卸しを行う" style={{ maxWidth: "min(100%, 800px)", height: "auto" }} width="1200" height="522" data-path="images/blog/spreadsheet-directory/series-diff.svg" />
</Frame>

## 従業員マスターSaaSを繋がない構成が向いている場面

SaaS管理ツールでは、従業員マスターを連携するのが基本です。

Google WorkspaceやMicrosoft Entra IDを従業員マスターとして繋ぐと、管理対象外の全社員アカウントまでディレクトリに入ってきます。管理対象外のアカウントは入れずに、自部署の従業員だけをディレクトリに入れたい場合は、従業員マスターを繋がない構成が向いています。

なお、グループ会社ごとにGoogle Workspaceなどのテナントが分かれていて、1つのSaaSを従業員マスターに決められない場合にも、同じ構成が使えます。

## 全体構成：スプレッドシート台帳を従業員マスター代わりにするデータ動線

構成はシンプルです。Adminaの**設定** > **組織** > **従業員マスター設定**は**あえて設定せず**、現場で管理しているディレクトリ台帳スプレッドシートを大元データとして、Adminaのディレクトリ（従業員台帳）に反映します。従業員マスターを設定しない場合にCSVでディレクトリ台帳を作ることは、[初期設定のヘルプページ](/it-management/tutorial/ikf8odm6ti-2)でも案内しています。

<Frame caption="従業員マスターSaaSを繋がない場合の全体構成">
  <img src="https://mintcdn.com/moneyforwardi/qhvusFsxlNhqU7CD/images/blog/spreadsheet-directory/overview.svg?fit=max&auto=format&n=qhvusFsxlNhqU7CD&q=85&s=d4f7090a2473c66a86f3e4332ecfa415" alt="従業員マスターSaaSを繋がない場合の全体構成図。ディレクトリ側では、部署のディレクトリ台帳スプレッドシートから、パターンA（CSVインポート、画面から手動）またはパターンB（GAS × Admina API、毎日自動）でAdminaのディレクトリへ反映する。SaaSアカウント側では、直接連携できるSaaSをOAuth・API・IDとパスワードでAdminaのSaaS管理に取り込む。Adminaはメールアドレスで名寄せし、退職アカウントを検知する" style={{ maxWidth: "min(100%, 800px)", height: "auto" }} width="1200" height="512" data-path="images/blog/spreadsheet-directory/overview.svg" />
</Frame>

台帳を大元にしても、**退職検知の仕組みはそのまま担保できます**。メールアドレスが一致していればAdminaディレクトリ上の同じ従業員に名寄せされるため、台帳側でステータスを「退職」に更新してAdminaに反映すれば、各SaaSに残っているアカウントを[アラート](/it-management/saas-management/q6jgukpnvh-alert)種別「退職アカウント」として検知できます。

## パターンA（基本）：ディレクトリ台帳CSVインポートによる反映

基本の方法です。Adminaの標準機能だけで完結し、大まかな流れは3ステップです。

1. ディレクトリ > **インポート**からテンプレートCSVをダウンロードする（既存データをエクスポートして修正する形でもOK）
2. 台帳スプレッドシートの内容をテンプレートの項目に合わせて成形し、CSVとして書き出す
3. ディレクトリ > **インポート**からCSVを取り込む

画面ごとの詳しい手順や登録できる項目の仕様は、[ディレクトリ台帳CSVのインポート手順](/it-management/directory/1xtq0cxipa-directory-csv-import)にまとまっています。

運用中の作業は、人の出入りがあったときに台帳をCSVで書き出してアップロードするだけです。更新頻度が多くない場合は、この方法でも十分に回るでしょう。更新頻度が高くアップロードも自動にしたい場合は、次のパターンBを参考にしてください。

## パターンB（応用）：GAS×Admina APIで自動反映するサンプルコード

ここからは、反映を自動化したい方向けの応用です。

Adminaには公開APIが用意されており、ディレクトリの従業員情報（Identity）をAPI経由で取得・作成・更新できます。これを使ってGAS（Google Apps Script）で「台帳スプレッドシートを読み取り、Adminaディレクトリへ反映する」処理を書けば、**CSVの書き出しと手動アップロードが不要になります**。

### 事前準備

* Adminaの設定画面からAPIキーを作成します（手順は[IT Management APIの認証ガイド](/api-reference/it-management/authentication)）
* 組織ID（Admina管理画面のURLに含まれる数値）を控えておきます
* 台帳のメールアドレスのドメインが、**設定** > **組織** > **ドメイン**に登録されているか確認します。組織を作成したときのプライマリドメイン以外を使う場合は、追加が必要です。登録がないと外部IDとして扱われ、正社員など社内IDでしか選べない雇用形態では作成がエラーになります

### 使うエンドポイントは3つだけ

| 操作 | エンドポイント | 用途 |
| - | - | - |
| 一覧取得 | `GET /api/v1/organizations/{organizationId}/identity` | 既存の従業員を取得し、メールアドレスで台帳と突合 |
| 作成 | `POST /api/v1/organizations/{organizationId}/identity` | 台帳にいてAdminaにいない従業員を新規登録 |
| 更新 | `PUT /api/v1/organizations/{organizationId}/identity/{identityId}` | ステータス変更（就業中→退職など）や属性変更を反映 |

APIリファレンス: [Identity一覧取得](/api-reference/it-management/directory/list-identities) / [Identity作成](/api-reference/it-management/directory/create-a-new-identity) / [Identity更新](/api-reference/it-management/directory/updates-an-identity)

### 処理フロー（upsert方式）

<Frame caption="GASスクリプトの処理フロー">
  <img src="https://mintcdn.com/moneyforwardi/qhvusFsxlNhqU7CD/images/blog/spreadsheet-directory/sync-flow.svg?fit=max&auto=format&n=qhvusFsxlNhqU7CD&q=85&s=4e88a85efede20d5fd7e69c174b8ea7a" alt="GASスクリプトの処理フロー図。毎日1回、①ディレクトリ台帳スプレッドシートを読み込み、②Identity一覧APIで取得したAdminaの従業員とメールアドレスで照合する。③台帳にいてAdminaにいない人はPOSTで新規作成し、両方にいて差分がある人はPUTで更新する。④台帳にいないのにAdminaで就業中の人は警告のみ出し、退職にはしない。差分がない人には何もしない" style={{ maxWidth: "min(100%, 800px)", height: "auto" }} width="1200" height="512" data-path="images/blog/spreadsheet-directory/sync-flow.svg" />
</Frame>

### 実装のポイント：ステータスのマッピング

この連携の肝は、台帳の「ステータス」列をAdminaのステータス（`employeeStatus`）に変換する部分です。台帳に最終勤務日を入れておけば、その翌日の0時台にスクリプトが退職としてAdminaに反映します。最終勤務日は、Adminaの契約終了日（`contractEndAt`）に入ります。ステータス列を手で書き換えなくても、各SaaSに残ったアカウントは退職アカウントアラートで検知できます。

<Info>
  スクリプトが書き換えるのはAdmina側だけで、台帳は変更しません。最終勤務日を過ぎても、台帳のステータス列は「就業中」のまま残ります。台帳の上でも退職した人が分かるようにしたい場合は、ステータスを「退職」に変えてください。変えても変えなくても、Adminaに反映される結果は同じです。スクリプトが台帳のステータスを書き換えないのは、退職日ではなく最終勤務日を基準にアカウントを消すなど、組織によって運用が違うためです。ご自身の組織の運用に合わせて、スクリプトを書き換えてください。
</Info>

以下はサンプルコードです。台帳の列構成（A列: メールアドレス、B列: 姓、C列: 名、D列: ステータス、E列: 雇用形態、F列: 部署、G列: 最終勤務日）に合わせて、冒頭の `COL` 定義とシート名を変えて使ってください。コードは台帳スプレッドシートの「拡張機能 > Apps Script」に貼り付けます。台帳をこれから作る場合は、`setupRosterSheet` を1回実行すると、この列構成のシートを用意できます。ステータスと雇用形態の列はプルダウンになるので、表記ゆれを防げます。本番の台帳で使う前に、少人数の台帳で動きを確かめてください。

<Frame caption="setupRosterSheet で用意した台帳の例。ステータスと雇用形態の列はプルダウンで、メニューに「Admina」が加わる">
  <img src="https://mintcdn.com/moneyforwardi/qhvusFsxlNhqU7CD/images/blog/spreadsheet-directory/spreadsheet-employee-directory.png?fit=max&auto=format&n=qhvusFsxlNhqU7CD&q=85&s=06062530a9f2bf538d56ebedda049885" alt="Googleスプレッドシートのディレクトリ台帳の画面。1行目にメールアドレス・姓・名・ステータス・雇用形態・部署・最終勤務日の列が並び、画面下のタブは「従業員一覧」。2行目以降に従業員の行がある。ステータス列と雇用形態列はプルダウンで選ぶ形式になっている。メニューバーの右端に Admina メニューが表示されている" style={{ maxWidth: "min(100%, 800px)", height: "auto" }} width="1452" height="772" data-path="images/blog/spreadsheet-directory/spreadsheet-employee-directory.png" />
</Frame>

<Accordion title="GASコード全文（クリックで開きます）">
  ```javascript theme={null}
  /**
   * ディレクトリ台帳スプレッドシート → Adminaディレクトリ（Identity）同期スクリプト
   *
   * 使い方:
   * 1. 台帳スプレッドシートの「拡張機能 > Apps Script」を開き、このコードを貼り付ける
   *    ※スクリプトと API キーは、台帳を編集できる人全員が見られる。台帳の編集者は API キーを見られてもいい人だけにする
   * 2. プロジェクトの設定 > スクリプト プロパティに、次の2つを登録する
   *    ADMINA_API_KEY: Admina の API キー / ADMINA_ORGANIZATION_ID: 組織ID（Admina 管理画面の URL に含まれる数値）
   * 3. SHEET_NAME・列定義（COL）を自社の台帳に合わせて修正する
   * 4. 台帳を新しく作る場合は、setupRosterSheet を1回実行してヘッダー行・サンプル行・プルダウンを用意する
   *    （既存の台帳を使う場合は不要。サンプル行は実際の従業員に書き換えるか削除する）
   * 5. 手動で syncDirectoryFromSheet を実行して動作確認する
   * 6. createDailyTrigger を1回だけ実行し、毎日自動実行のトリガーを設定する
   * 7. 台帳を開き直すと「Admina」メニューが出る。急ぎのときは「Admina > 今すぐ反映」で同期できる
   *    （初めて実行する人は、Google の承認画面でスクリプトの実行を許可する）
   *
   * 運用ルール: 退職した人の行は削除せず、ステータスを「退職」にするか最終勤務日を入れてください。
   * 行を削除しても Admina 側は就業中のまま残ります（実行ログに警告を出します）。
   */

  // ===== 設定（自社の環境に合わせて変更）=====
  const SHEET_NAME = "従業員一覧" // 台帳シートの名前
  const TIME_ZONE = "Asia/Tokyo" // 最終勤務日を判定するタイムゾーン

  // 台帳の列定義（1行目はヘッダー行として読み飛ばす）
  const COL = {
    EMAIL: 0,        // A列: メールアドレス（突合キー）
    LAST_NAME: 1,    // B列: 姓
    FIRST_NAME: 2,   // C列: 名
    STATUS: 3,       // D列: ステータス（就業中 / 休職中 / 退職）
    EMP_TYPE: 4,     // E列: 雇用形態（正社員 / 契約社員 / ...）
    DEPT: 5,         // F列: 部署名
    CONTRACT_END: 6, // G列: 最終勤務日（任意・日付セル or YYYY-MM-DD）。Admina の契約終了日に入る
  }

  // 台帳の日本語表記 → Admina APIのenum値
  const STATUS_MAP = {
    "就業中": "active",
    "休職中": "on_leave",
    "退職": "retired",
  }
  const EMP_TYPE_MAP = {
    "正社員": "full_time_employee",
    "契約社員": "fixed_time_employee",
    "派遣社員": "temporary_employee",
    "パート・アルバイト": "part_time_employee",
    "業務委託": "contract_employee", // コラボレーターは外部ID専用で、自社ドメインの人には使えない
    "その他": "other",
  }

  /**
   * メイン処理: 台帳を読み、Adminaディレクトリへ upsert（新規作成 or 差分更新）する
   */
  function syncDirectoryFromSheet() {
    // APIキーと組織IDはコードに直書きせず、スクリプトプロパティから取得する
    const props = PropertiesService.getScriptProperties()
    const apiKey = props.getProperty("ADMINA_API_KEY")
    const organizationId = props.getProperty("ADMINA_ORGANIZATION_ID")
    if (!apiKey) throw new Error("スクリプトプロパティ ADMINA_API_KEY が設定されていません")
    if (!organizationId) throw new Error("スクリプトプロパティ ADMINA_ORGANIZATION_ID が設定されていません")
    const api = { key: apiKey, base: `https://api.itmc.i.moneyforward.com/api/v1/organizations/${organizationId}` }

    // メニューからの実行と毎日のトリガーが重なると二重に新規作成されるので、同時には1つだけ動かす
    const lock = LockService.getScriptLock()
    if (!lock.tryLock(30 * 1000)) throw new Error("別の同期が実行中です。少し待ってから、もう一度実行してください")
    try {
      return syncWithLock_(api)
    } finally {
      lock.releaseLock()
    }
  }

  function syncWithLock_(api) {

    const { members, emails, errors } = readRosterSheet_() // ① 台帳シートを読み込む（読めない行は errors へ）
    const identities = fetchAllIdentities_(api) // ② 既存Identityを全件取得（cursorでページング）
    const byEmail = new Map(
      identities
        .filter((i) => i.primaryEmail)
        .map((i) => [i.primaryEmail.toLowerCase(), i])
    )

    let created = 0
    let updated = 0
    let unchanged = 0
    const warnings = []

    for (const member of members) {
      const existing = byEmail.get(member.email)
      try {
        if (!existing) {
          const identity = createIdentity_(api, member) // ③-a 台帳にいてAdminaにいない → 新規作成
          warnIfNotManaged_(identity, warnings)
          created++
        } else if (needsUpdate_(existing, member)) {
          const identity = updateIdentity_(api, existing.id, member) // ③-b 差分あり → 更新
          warnIfNotManaged_(identity, warnings)
          updated++
        } else {
          warnIfNotManaged_(existing, warnings)
          unchanged++
        }
      } catch (e) {
        // 1件の失敗で全体を止めず、ログに溜めて最後にまとめて確認する
        errors.push(`${member.email}: ${explainError_(e.message)}`)
      }
    }

    // ④ 台帳から消えたのに Admina で就業中のまま残っている社内IDを洗い出す（自動では退職にしない）
    for (const identity of identities) {
      const email = (identity.primaryEmail || "").toLowerCase()
      if (identity.managementType === "managed" && identity.employeeStatus === "active" && !emails.has(email)) {
        warnings.push(`${email || identity.id}: 台帳に行がないのに Admina では就業中です。退職した人なら、台帳に行を戻してステータスを「退職」にしてください`)
      }
    }

    const summary = `同期完了: 新規 ${created}件 / 更新 ${updated}件 / 変更なし ${unchanged}件`
    console.log(summary)
    if (warnings.length > 0) {
      console.warn(`要確認 ${warnings.length}件\n${warnings.join("\n")}`)
    }
    if (errors.length > 0) {
      console.error(`失敗 ${errors.length}件\n${errors.join("\n")}`)
    }
    return { summary, warnings, errors }
  }

  /** ① 台帳シートを読み込んで従業員の配列に変換する */
  function readRosterSheet_() {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME)
    if (!sheet) throw new Error(`シート「${SHEET_NAME}」が見つかりません`)

    const today = Utilities.formatDate(new Date(), TIME_ZONE, "yyyy-MM-dd")
    const rows = sheet.getDataRange().getValues().slice(1) // ヘッダー行を除く
    const members = []
    const emails = new Set() // エラーの行も含めた、台帳にあるメールアドレス全件
    const errors = []

    rows.forEach((row, i) => {
      const rowNo = i + 2 // シート上の行番号（1行目はヘッダー）
      const email = String(row[COL.EMAIL]).trim().toLowerCase()
      if (!email) return // メールアドレスが空の行は対象外
      if (email === SAMPLE_EMAIL) {
        errors.push(`${rowNo}行目 ${email}: setupRosterSheet が入れたサンプル行です。実際の従業員に書き換えるか、行を削除してください`)
        return
      }
      emails.add(email)

      // 知らない表記を「就業中」扱いにすると退職検知が漏れるので、その行は反映せずに知らせる
      const statusLabel = String(row[COL.STATUS]).trim()
      const status = lookup_(STATUS_MAP, statusLabel)
      if (!status) {
        errors.push(`${rowNo}行目 ${email}: ステータス「${statusLabel}」は使えません（${Object.keys(STATUS_MAP).join(" / ")} のいずれか）`)
        return
      }
      const typeLabel = String(row[COL.EMP_TYPE]).trim()
      const employeeType = typeLabel ? lookup_(EMP_TYPE_MAP, typeLabel) : "unknown"
      if (!employeeType) {
        errors.push(`${rowNo}行目 ${email}: 雇用形態「${typeLabel}」は使えません（${Object.keys(EMP_TYPE_MAP).join(" / ")} のいずれか）`)
        return
      }
      const contractEndAt = toDateString_(row[COL.CONTRACT_END])
      if (contractEndAt && !/^\d{4}-\d{2}-\d{2}$/.test(contractEndAt)) {
        errors.push(`${rowNo}行目 ${email}: 最終勤務日「${row[COL.CONTRACT_END]}」は日付として読めません（日付セルか YYYY-MM-DD で入力）`)
        return
      }

      members.push({
        email,
        lastName: String(row[COL.LAST_NAME]).trim(),
        firstName: String(row[COL.FIRST_NAME]).trim(),
        // 最終勤務日を過ぎた人（翌日以降）は、ステータス列の値にかかわらず退職として反映する
        employeeStatus: contractEndAt && contractEndAt < today ? "retired" : status,
        employeeType,
        department: String(row[COL.DEPT]).trim(),
        contractEndAt,
      })
    })

    return { members, emails, errors }
  }

  /** よくある API エラーに、原因の手がかりを添える */
  function explainError_(message) {
    // ドメインが未登録だと外部IDとして扱われ、正社員など社内ID専用の雇用形態が不正と判定される
    if (message.includes("identity_invalid_employee_type")) {
      return `${message}（メールアドレスのドメインが 設定 > 組織 > ドメイン に登録されているか確認してください）`
    }
    return message
  }

  /** 対応表にある表記ならAPIの値を、なければ null を返す */
  function lookup_(map, label) {
    return Object.prototype.hasOwnProperty.call(map, label) ? map[label] : null
  }

  /** 社内IDとして登録されていなければ警告に積む（ドメイン未登録のとき外部IDなどに分類されるため） */
  function warnIfNotManaged_(identity, warnings) {
    if (identity && identity.managementType && identity.managementType !== "managed") {
      warnings.push(`${identity.primaryEmail}: 社内IDではなく「${identity.managementType}」として登録されています。設定 > 組織 > ドメイン にメールアドレスのドメインが登録されているか確認してください`)
    }
  }

  /** ② Identity一覧APIをページングしながら全件取得する */
  function fetchAllIdentities_(api) {
    const all = []
    let cursor = null
    do {
      const query = cursor ? `?limit=200&cursor=${encodeURIComponent(cursor)}` : "?limit=200"
      const res = request_(api, "get", `/identity${query}`)
      all.push(...res.items)
      cursor = res.meta.nextCursor // 次ページがなければ null になる
    } while (cursor)
    return all
  }

  /** 差分があるか判定する（無駄な更新リクエストを打たないため） */
  function needsUpdate_(existing, member) {
    return (
      existing.employeeStatus !== member.employeeStatus ||
      existing.employeeType !== member.employeeType ||
      existing.lastName !== member.lastName ||
      existing.firstName !== member.firstName ||
      (existing.department?.name || "") !== member.department ||
      toDateString_(existing.lifecycle?.contractEndAt) !== member.contractEndAt
    )
  }

  /** ③-a 新しい従業員を作成する（作成したIdentityを返す） */
  function createIdentity_(api, member) {
    return request_(api, "post", "/identity", toPayload_(member))
  }

  /** ③-b 既存の従業員を更新する（更新後のIdentityを返す） */
  function updateIdentity_(api, identityId, member) {
    return request_(api, "put", `/identity/${identityId}`, toPayload_(member))
  }

  /** Admina APIに渡すリクエストボディを組み立てる */
  function toPayload_(member) {
    return {
      employeeStatus: member.employeeStatus, // 必須: active / retired など
      employeeType: member.employeeType,     // 必須: full_time_employee など
      lastName: member.lastName,             // 必須
      firstName: member.firstName,           // 必須
      displayName: `${member.lastName} ${member.firstName}`,
      primaryEmail: member.email,
      department: { name: member.department || null }, // 空欄なら null で部署を消す（department ごと null だと変更されない）
      lifecycle: { contractEndAt: member.contractEndAt }, // 契約終了日（台帳の最終勤務日）
    }
  }

  /** HTTPリクエストの共通処理 */
  function request_(api, method, path, payload) {
    const res = UrlFetchApp.fetch(api.base + path, {
      method,
      contentType: "application/json",
      headers: { Authorization: `Bearer ${api.key}` },
      payload: payload ? JSON.stringify(payload) : undefined,
      muteHttpExceptions: true, // エラー時も例外にせず、自分でハンドリングする
    })
    const code = res.getResponseCode()
    const body = res.getContentText()
    if (code >= 400) {
      throw new Error(`APIエラー ${code}: ${body.slice(0, 200)}`)
    }
    return body ? JSON.parse(body) : {}
  }

  /** 日付セル・ISO文字列を YYYY-MM-DD に正規化する */
  function toDateString_(value) {
    if (!value) return null
    if (value instanceof Date) {
      return Utilities.formatDate(value, TIME_ZONE, "yyyy-MM-dd")
    }
    return String(value).slice(0, 10) // "2026-09-30T00:00:00.000Z" → "2026-09-30"
  }

  const SAMPLE_EMAIL = "taro.yamada@example.com" // setupRosterSheet が入れるサンプル行（同期では反映しない）

  /** 台帳を新しく作るときに1回だけ実行: ヘッダー行・サンプル行・入力規則を用意する */
  function setupRosterSheet() {
    const ss = SpreadsheetApp.getActiveSpreadsheet()
    const sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME)
    if (sheet.getLastRow() > 0) {
      throw new Error(`シート「${SHEET_NAME}」には既にデータがあります。上書きしないよう、空のシートで実行してください`)
    }

    const header = { EMAIL: "メールアドレス", LAST_NAME: "姓", FIRST_NAME: "名", STATUS: "ステータス", EMP_TYPE: "雇用形態", DEPT: "部署", CONTRACT_END: "最終勤務日" }
    const sample = { EMAIL: SAMPLE_EMAIL, LAST_NAME: "山田", FIRST_NAME: "太郎", STATUS: "就業中", EMP_TYPE: "正社員", DEPT: "情報システム部", CONTRACT_END: "" }
    const width = Math.max(...Object.values(COL)) + 1
    const toRow = (values) => {
      const row = new Array(width).fill("")
      for (const [key, index] of Object.entries(COL)) row[index] = values[key]
      return row
    }
    sheet.getRange(1, 1, 2, width).setValues([toRow(header), toRow(sample)])
    sheet.getRange(1, 1, 1, width).setFontWeight("bold")
    sheet.setFrozenRows(1)

    // 表記ゆれの行は同期でエラーになるので、ステータス・雇用形態はプルダウン、最終勤務日は日付だけ入力できるようにする
    const rows = sheet.getMaxRows() - 1
    const listRule = (values) =>
      SpreadsheetApp.newDataValidation().requireValueInList(values, true).setAllowInvalid(false).build()
    sheet.getRange(2, COL.STATUS + 1, rows).setDataValidation(listRule(Object.keys(STATUS_MAP)))
    sheet.getRange(2, COL.EMP_TYPE + 1, rows).setDataValidation(listRule(Object.keys(EMP_TYPE_MAP)))
    sheet
      .getRange(2, COL.CONTRACT_END + 1, rows)
      .setDataValidation(SpreadsheetApp.newDataValidation().requireDate().setAllowInvalid(false).build())
      .setNumberFormat("yyyy-mm-dd")
  }

  /** 台帳を開いたときに「Admina」メニューを出す */
  function onOpen() {
    SpreadsheetApp.getUi().createMenu("Admina").addItem("今すぐ反映", "syncFromMenu").addToUi()
  }

  /** メニューから同期し、結果を画面に出す（台帳の編集者は実行ログを見に行かなくて済む） */
  function syncFromMenu() {
    const ui = SpreadsheetApp.getUi()
    try {
      const { summary, warnings, errors } = syncDirectoryFromSheet()
      const details = [
        ...(errors.length > 0 ? [`失敗 ${errors.length}件`, ...errors] : []),
        ...(warnings.length > 0 ? [`要確認 ${warnings.length}件`, ...warnings] : []),
      ]
      ui.alert([summary, ...details].join("\n"))
    } catch (e) {
      ui.alert(`同期できませんでした: ${e.message}`)
    }
  }

  /** 初回に1回だけ実行: 毎日1回・0時台に同期する時間主導型トリガーを作る */
  function createDailyTrigger() {
    // 既存の同名トリガーがあれば消してから作り直す（重複防止）
    ScriptApp.getProjectTriggers()
      .filter((t) => t.getHandlerFunction() === "syncDirectoryFromSheet")
      .forEach((t) => ScriptApp.deleteTrigger(t))

    ScriptApp.newTrigger("syncDirectoryFromSheet")
      .timeBased()
      .everyDays(1)
      .atHour(0) // 毎日0時台に実行
      .create()
  }
  ```
</Accordion>

### 運用・セキュリティのポイント

<Frame caption="台帳の編集者と部署の管理者の権限の分け方">
  <img src="https://mintcdn.com/moneyforwardi/qhvusFsxlNhqU7CD/images/blog/spreadsheet-directory/permissions.svg?fit=max&auto=format&n=qhvusFsxlNhqU7CD&q=85&s=113a8a59d90252d4a3b58f464a47967d" alt="権限の分け方の図。ディレクトリ台帳スプレッドシートの中に、従業員一覧シートと、台帳に紐づく同期スクリプトがある。同期スクリプトはAPIキーをスクリプトプロパティに保存し、メニューの今すぐ反映と毎日1回のトリガーで実行され、Admina APIでAdminaのディレクトリに反映する。台帳の編集者は全員スクリプトとAPIキーを見られるので、編集権限はAPIキーを見られてもいい人だけに渡す。Adminaにログインするのは部署の管理者だけ" style={{ maxWidth: "min(100%, 800px)", height: "auto" }} width="1200" height="450" data-path="images/blog/spreadsheet-directory/permissions.svg" />
</Frame>

* APIキーはGASの**スクリプトプロパティ**に保存し、コードやシートに直書きしないでください
* 台帳の編集者は、APIキーを見られてもいい人だけにしてください。台帳に紐づくスクリプトとスクリプトプロパティは、台帳を編集できる人全員が見られます。台帳を見るだけの人は、閲覧権限で共有します
* 外部の委託先など、APIキーを見せたくない人も台帳を編集する場合は、スクリプトを台帳とは別のスタンドアロンのApps Scriptプロジェクトに作って分けてください。コードの `SpreadsheetApp.getActiveSpreadsheet()` を `SpreadsheetApp.openById("台帳のID")` に変えれば動きます。その場合メニューは出ないので、反映は毎日のトリガーか、部署の管理者による手動実行になります
* 時間主導型トリガーで毎日実行しておけば、日々の作業は「台帳シートを更新するだけ」になります
* すぐに反映したいとき（入社当日や退職当日など）は、台帳のメニューの **Admina > 今すぐ反映** から同期できます。結果の件数とエラーは画面に出ます。何度実行しても、差分がある人だけを反映します。初めて実行する人は、Googleの承認画面でスクリプトの実行を許可してください
* 退職した人の行は削除せず、ステータスを「退職」にするか最終勤務日を入れてください。行を削除しても、Admina側では就業中のまま残ります（スクリプトは警告を出します）。この警告は、台帳にいない社内IDで就業中の人全員が対象です。部署の管理者など、台帳の対象外の人が組織にいる場合は、その人も台帳に行を足しておくと、毎回の警告が出なくなります
* Adminaのアカウントは、部署の管理者だけが持てば足ります。台帳の編集者はAdminaにログインしなくても、台帳の更新と反映ができます

## 2パターンの使い分け

| 観点 | パターンA（CSVインポート） | パターンB（GAS×API） |
| - | - | - |
| 初期コスト | なし（画面操作のみ） | GASコード作成・APIキー管理 |
| 日々の手間 | 更新のたびにCSV書き出し＆手動アップロード | 台帳シートの更新のみ（毎日自動で反映） |
| 向いている現場 | まず始める部署（画面操作だけで運用したい） | アップロードの手間も省きたい部署 |
| 必要スキル | 不要（画面操作のみ） | GASの基本操作（コードの貼り付け・スクリプトプロパティ・トリガー設定） |
| 主な注意点 | 台帳にない列は空で上書きされる | APIキーの管理（台帳の編集者は全員見られる） |

パターンAから始めて、アップロードの手間も省きたくなったらパターンBを試してみてください。

## まとめ：部署単位で始めるAdminaのSaaS管理スモールスタート

Adminaは、従業員マスターを連携しなくても始められます。**現場のスプレッドシート台帳を大元にして、自部署の従業員とSaaSだけを管理する**形でも運用できます。

いま使っている台帳スプレッドシートは、作り直さなくて大丈夫です。そのままAdminaに反映すれば、退職検知や棚卸しの対象にできます。基本はCSVインポートで始められます。アップロードの手間も省きたくなったら、サンプルコードで反映を自動化できます。まずは自部署の台帳スプレッドシートを繋ぐところから始めてみてください。

<Card title="Admina の資料を請求する（資料 3 点セット）" icon="file-lines" href="https://admina.moneyforward.com/jp/3set" />

<BlogAuthor id="howdy39" />


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.