コンテンツへスキップ

SuccessFactorsを内製アプリで機能拡張する方法

(最終更新:2026年6月)

SAP SuccessFactorsは人事・タレントマネジメントシステムとして多くの企業に活用されています。

一方で、グローバル標準の設計思想に基づいたシステムであるため、日本企業特有の運用や、自社独自の人事施策を標準機能だけで実現するのが難しいケースもあります。

標準機能で対応しきれない部分をすべて手作業や運用ルールで補おうとすると、人事担当者の負担が増えたり、データの一貫性が損なわれたりする可能性があります。

こうした課題に対する有効な選択肢のひとつが、SuccessFactors のデータを API で取得し、自社要件に合わせた内製アプリと連携する方法です。

この記事では、SuccessFactors のポジション情報と、別システムで管理している JD(ジョブディスクリプション)情報を連携し、Google Cloud 上で JD付きのポジション組織図アプリを作成する方法を紹介します。

アプリの完成イメージ

JD付きのポジション組織図の完成イメージです。ぜひ触ってみてください。

画面をドラッグすると上下左右に移動でき、マウスホイールで拡大・縮小できます。ポジションをクリックすると、そのポジションに紐づくJD情報をモーダル画面で表示します。上部にはポジションの表示形式を切り替えるボタンを配置しています。ポジションの検索機能などを付けることもできます。

サンプルJD
顔写真

ポジション名

担当者名

業務内容

  • ※サンプルなので全ポジション同じ簡素なJD表示してます
  • 担当領域における課題抽出、施策立案、実行推進
  • 社内外ステークホルダーとの調整およびプロジェクト進行管理
  • KPIの設計、モニタリング、改善アクションの実施
  • 業務フローの整備、標準化、ナレッジ共有
  • 組織目標達成に向けた横断的なコミュニケーションと支援

SuccessFactorsのポジション組織図は、無駄な余白が多いことや、親子関係を展開するのに時間がかかる点から、組織の全体像を把握するには適していません。一方で、このような組織図では構造が瞬時に可視化されるだけでなく、SuccessFactorsにないJDなどの情報を組み合わせることで、「誰が・何を担っているのか」まで直感的に把握できるようになっています。

活用シーンに応じて設計・カスタマイズするのも自由です。たとえば、組織間のコミュニケーション促進、組織運営の評価、ヘッドカウントの可視化、人材公募の準備など、それぞれの用途に応じて必要なデータや表現は異なります。こうした「目的」を主軸に設計することで、SuccessFactorsの標準機能では実現が難しかった、意思決定を支える実用的なツールとして活用することができます。

そもそもGoogle Cloudとは

Google Cloud は、Google が提供するクラウドサービス群です。

従来、システムを構築するには、サーバーとなるコンピュータを自社で用意し、設置場所やネットワーク、ソフトウェアの稼働状況まで管理する必要がありました。一方で、Google Cloud を利用すれば、物理的なサーバーを自社で持たなくても、インターネット経由で必要な機能を必要な分だけ利用できます。

初期投資を抑えてシステムを構築しやすい点も大きな特徴です。構成や利用量にもよりますが、小規模なアプリであれば月数千円から数万円程度で運用できるケースもあります。また、従量課金制のため、使い始めやすく、不要になった場合に停止・見直ししやすい点もメリットです。

以前は、大規模なシステム開発を外部ベンダーに依頼し、多額の費用と時間をかけて導入するケースが一般的でした。一方で、導入したシステムが自社の実務に十分になじまなかったり、運用開始後の改善が進まなかったりする場合、せっかく構築した仕組みを十分に活用できないこともあります。

現在では、クラウドサービスや生成AIの普及により、自社の業務課題に合わせて、人事システムを社内主導で改善していくことが可能です。特に、人事業務とシステムの両方を理解している人材が人事部門にいれば、PoC(概念実証)を素早く回しながら、会社が本当に必要とする仕組みを育てていくことができます。

アプリの全体構成

このアプリはSuccessFactorsのトップ画面にURLを貼っておくことで、あたかもSuccessFactorsの機能の一つであるかのように、ポジション組織図を表示できます。

システム設計を簡素化して図示すると下記のようになります。「誰がアクセスするのか」「どのタイミングでデータを取得するのか」「どこまでの情報を見せるのか」といった条件によって、設計は変わってきます。

ポジション情報は機密情報のため、外部のユーザーがアクセスできないよう、Entra IDなどの社内認証基盤と連携します。

また、ユーザーがアプリを開くたびにSuccessFactorsからデータを取得すると、レスポンスの遅延や不要な通信コストが発生します。そのため、ポジション情報はあらかじめ定期的にSuccessFactorsから取得し、Google Cloud上に保存しておきます。

アプリの構築方法

※ここからは具体的な実装方法なので、興味ある方のみご覧ください

Google Cloudでの設定方法を紹介しますが、AWSでも類似の構成になると思います。また、SAPにもBTP(Business Technology Platform)という、Google Cloudに似たクラウドサービスがあります。しかし、BTPにはSAP特有の癖があること、保守にはSAP関連システムに精通した人材が必要になること、さらに拡張性や柔軟性の面ではGoogle Cloudのほうが優れていると考えられることから、Google Cloudを採用しています。

また、本記事では必要最低限のリソースを使用した構成で構築しています。実際の利用状況に応じて、適宜スケールアップやサービスアカウントの権限管理を行ってください。

サーバーレスVPCアクセスの作成

Cloud Runなどのサーバーレス環境から、SQLなどのVPC内のリソースへ安全に接続するための機能です。

  1. Google Cloud ConsoleでVPCネットワークのサーバーレスVPCアクセスを開く。
  2. 「コネクタを作成する」をクリックする。
  3. 任意のコネクタ名を入力する。
  4. リージョンは「asia-northeast2(大阪)」を選択する。
  5. ネットワークは「default」を選択する。
  6. サブネットは「カスタムIP範囲」で「192.168.50.0/28」を入力する。
  7. スケーリング設定の最小インスタンスに「2」最大インスタンス数に「5」を入力する。
  8. インスタンスのタイプは「f1-micro」を選択する。
  9. 「作成」をクリックする。

Cloud SQLインスタンスの作成

Google Cloud上でリレーショナルデータベースを管理・運用できるサービスです。

  1. Google Cloud ConsoleでCloud SQLを開く。
  2. 「インスタンスを作成」をクリックする。
  3. 「MySQLを選択」をクリックする。
  4. データベースのバージョンは「MySQL 8.0」を選択する。
  5. 任意のインスタンスIDを入力する。
  6. 任意のパスワードを入力する。
  7. リージョンは「asia-northeast2(大阪)」を選択する。
  8. ゾーンの可用性は「シングルゾーン」を選択する。
  9. マシンシェイプは「共有コア」の「1 vCPU、1.7GB」を選択する。
  10. ストレージは「SSD」の「10GB」を選択し、ストレージの自動増量を無効にする。
  11. インスタンスIPの割り当ては「プライベートIP」の「default」ネットワークを選択し、「パブリックIP」は無効にする。
  12. プライベートサービスアクセス接続、API、自動割り当てを選択する。
  13. 「プライベートパス」を有効にする。
  14. 日時パックアップの自動化では、バックアップ日数は「30」、バックアップの時間枠は「1:00-5:00」、リージョンで「asia-northeast2(大阪)」を選択し、ポイントタイプリカバリを「7」日で有効にする。
  15. メンテナンスのタイミングを「任意」で時間枠を「日曜日」の「0:00-1:00」にする。
  16. 「インスタンスを作成」をクリックする。
  17. Consoleの「IAMと管理」を開く。
  18. 「Compute Engine default service account」の鉛筆ボタンをクリックし、「別のロールを追加」から「Cloud SQLクライアント」を追加して保存する。

SQLインスタンスの自動起動・停止

Cloud Run FunctionsでSQLインスタンスを自動起動・停止する関数を作成します。

  1. Google Cloud ConsoleでCloud Run Functionsを開く。
  2. 「関数を作成」をクリックする。
  3. 任意の関数名を入力する。
  4. リージョンは「asia-northeast2(大阪)」を選択する。
  5. トリガーのタイプは「Cloud Pub/Sub」を選択する。
  6. トピックは「InstanceMgmt」をデフォルト設定で作成して選択する。
  7. 「次へ」をクリックする。
  8. 関数作成ページでランタイムに「Python3.12」を選択し、エントリポイントに「process_pubsub」を入力する。
  9. pythonコードとrequirements.txtの中身を以下にしてデプロイする。
process_pubsubのpythonコード
import base64
import json
import logging

import google.auth
from googleapiclient import discovery
from functions_framework import cloud_event


VALID_ACTIONS = {
    "start": "ALWAYS",
    "stop": "NEVER",
}


@cloud_event
def process_pubsub(event):
    try:
        message = event.data.get("message", {})
        encoded_data = message.get("data")

        if not encoded_data:
            raise ValueError("Pub/Sub message is missing required 'data' field.")

        message_data = base64.b64decode(encoded_data).decode("utf-8")
        ps_data = json.loads(message_data)

        if not isinstance(ps_data, dict):
            raise ValueError("Pub/Sub message data must be a JSON object.")

    except Exception as e:
        logging.exception("Failed to decode or parse Pub/Sub data: %s", e)
        raise

    instance = ps_data.get("instance")
    project = ps_data.get("project")
    action_requested = ps_data.get("action", "").lower()

    missing_fields = [
        field_name
        for field_name, field_value in {
            "instance": instance,
            "project": project,
            "action": action_requested,
        }.items()
        if not field_value
    ]

    if missing_fields:
        raise ValueError(f"Missing required field(s): {', '.join(missing_fields)}")

    if action_requested not in VALID_ACTIONS:
        raise ValueError("Invalid action provided. Valid options are: 'start' or 'stop'.")

    activation_policy = VALID_ACTIONS[action_requested]

    logging.info(
        "Request received for Cloud SQL instance '%s', action: '%s', project: '%s'",
        instance,
        action_requested,
        project,
    )

    credentials, _ = google.auth.default(
        scopes=["https://www.googleapis.com/auth/cloud-platform"]
    )

    service = discovery.build(
        "sqladmin",
        "v1beta4",
        credentials=credentials,
        cache_discovery=False,
    )

    request_body = {
        "settings": {
            "activationPolicy": activation_policy
        }
    }

    try:
        response = service.instances().patch(
            project=project,
            instance=instance,
            body=request_body,
        ).execute()

        logging.info(
            "Successfully patched Cloud SQL instance '%s' in project '%s': %s",
            instance,
            project,
            response,
        )

    except Exception as e:
        logging.exception(
            "Failed to patch Cloud SQL instance '%s' in project '%s': %s",
            instance,
            project,
            e,
        )
        raise

    return "Instance updated successfully."
requirements.txt
google-auth>=2.0.0,<3.0.0
google-api-python-client>=2.0.0,<3.0.0
functions-framework==3.*
  1. Consoleの「IAMと管理」を開く。
  2. 「App Engine default service account」の鉛筆ボタンをクリックし、「別のロールを追加」から「Cloud SQL管理者」を追加して保存する。
  3. Consoleの「Cloud Scheduler」を開く。
  4. 「ジョブの作成」をクリックする。
  5. 任意の名前を入力する。
  6. リージョンは「asia-northeast2(大阪)」を選択する。
  7. 頻度に「0 7 * * 1-5」を入力し、タイムゾーンは「日本標準時」を選択する。
  8. ターゲットは「Pub/Sub」の「InstanceMgmt」を選択し、以下の開始メッセージを入力し、「作成」をクリックする。
開始メッセージ
{
  "instance": "SQLインスタンス名",
  "project": "プロジェクト名",
  "action": "start"
}
  1. 同様に、「0 22 * * 1-5」のジョブを以下の停止メッセージで作成する。
停止メッセージ
{
  "instance": "SQLインスタンス名",
  "project": "プロジェクト名",
  "action": "stop"
}

VMインスタンスの作成

SQL操作やアプリのデプロイをするための仮想サーバーを作成します。

  1. Google Cloud ConsoleでCompute EngineのVMインスタンスを開く。
  2. 「インスタンスの作成」をクリックする。
  3. リージョンは「asia-northeast2(大阪)」を選択する。
  4. マシンタイプは「e2-small」を選択する。
  5. 「作成」をクリックする。
  6. 作成されたインスタンスをクリックし、「停止」をクリックし、停止されたら「編集」をクリックする。
  7. アクセススコープには「すべてのCloud APIに完全アクセス権を許可」を選択し「保存」をクリックする。
  8. インスタンスの「開始/再開」をクリックし、開始されたら「SSH」をクリックする。
  9. プロンプトが表示されたら「pwd」と入力し、/home/$USERディレクトリにいることを確認する。
  10. MySQLクライアントをインストールする。
    sudo apt-get update
    sudo apt-get install mariadb-client
    mysql --version
  11. Cloud SQL Auth Proxyをインストールする。
    sudo apt-get install wget
    wget https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.14.0/cloud-sql-proxy.linux.amd64 - O cloud-sql-proxy
    chmod +x cloud-sql-proxy

SQLのテーブル作成

VMインスタンスからSQLに接続し、SQLのテーブルを定義します。

  1. VMインスタンスのSSH画面でCloud SQL Auth Proxyを起動する。
    ./cloud-sql-proxy --private-ip プロジェクト名:asia-northeast2:SQLインスタンス名
  2. SSH画面右上の「新しい接続」から2つ目のSSH画面を開き、こちらでCloud SQLインスタンスに接続する。
    mysql -h 127.0.0.1 -P3306 -u root -p
  3. データベースを作成する。
データベース作成
CREATE DATABASE IF NOT EXISTS sfDatabase
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;
  1. データベースをUSE sfDatabase;で開き、ユーザー情報のテーブルとポジションのテーブルを作成する。
ユーザー情報のテーブル作成
CREATE TABLE IF NOT EXISTS sfUsers (
    userId VARCHAR(255) PRIMARY KEY,
    lastName VARCHAR(255),
    firstName VARCHAR(255),
    email VARCHAR(255) NOT NULL,
    status VARCHAR(255),
    photo MEDIUMBLOB
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ポジション情報のテーブル作成
CREATE TABLE IF NOT EXISTS sfPositions (
    code VARCHAR(255) PRIMARY KEY,
    effectiveStatus VARCHAR(255),
    externalName VARCHAR(255),
    parentPosition VARCHAR(255),
    incumbent VARCHAR(255),
    cust_Goal TEXT,
    cust_PlanningandExecution TEXT,
    cust_StructureReinforcement TEXT,
    cust_SkillandExperience TEXT,
    cust_Language TEXT,
    cust_Notes TEXT,
    lastModifiedDateTime VARCHAR(255)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

SuccessFactorsのAPIキーの取得

SuccessFactorsからAPIでデータするための準備をします。

  1. こちらの記事を参考に、API操作をするためのSuccessFactors側の設定をする。
  2. ConsoleのSecret Managerを開く。
  3. SuccessFactors のユーザー名、パスワード、およびその他の機密情報はSecret Manager に登録する。
  4. Consoleの「IAMと管理」を開く。
  5. 「Compute Engine default service account」の鉛筆ボタンをクリックし、「別のロールを追加」から「Secret Managerのシークレット アクセサー」を追加して保存する。

API操作の関数の作成

SuccessFactorsのユーザー情報とポジション情報をCloud SQLに保存する関数を作成します。

  1. Google Cloud ConsoleでCloud Run Functionsを開く。
  2. 「関数を作成」をクリックする。
  3. 任意の関数名を入力する。
  4. リージョンは「asia-northeast2(大阪)」を選択する。
  5. トリガーのタイプは「Cloud Pub/Sub」を選択する。
  6. トピックは「SuccessFactorsFetch」をデフォルト設定で作成して選択する。
  7. 「次へ」をクリックする。
  8. 関数作成ページでランタイムに「Python3.12」を選択し、エントリポイントに「process_pubsub」を入力する。
  9. pythonコードとrequirements.txtの中身を以下にしてデプロイする。
process_pubsubのpythonコード
import base64
import json
import logging

import mysql.connector
import networkx as nx
import pandas as pd
import requests

from google.cloud import secretmanager
from functions_framework import cloud_event


PROJECT_ID = "プロジェクト名"

# SuccessFactors の API Server URL
# 例: https://apiXX.successfactors.com
SERVER_URL = "SuccessFactorsのAPI URL"

REQUEST_TIMEOUT = 120
VALID_ACTIONS = {"user", "position"}

# OIDC / IAS
GRANT_TYPE_PASSWORD = "password"
GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"
RESOURCE_PREFIX = "urn:sap:identity:application:provider:name:"
OIDC_SCOPE = "openid"

SECRET_CLIENT = secretmanager.SecretManagerServiceClient()


def fetch_secret(secret_name):
    name = f"projects/{PROJECT_ID}/secrets/{secret_name}/versions/latest"
    response = SECRET_CLIENT.access_secret_version(request={"name": name})
    return response.payload.data.decode("UTF-8")


def decode_pubsub_event(event):
    try:
        message = event.data.get("message", {})
        encoded_data = message.get("data")

        if not encoded_data:
            raise ValueError("Pub/Sub message is missing required data.")

        message_data = base64.b64decode(encoded_data).decode("utf-8")
        ps_data = json.loads(message_data)

        if not isinstance(ps_data, dict):
            raise ValueError("Pub/Sub message data must be a JSON object.")

        action_requested = ps_data.get("action", "").lower().strip()

        if action_requested not in VALID_ACTIONS:
            raise ValueError("Invalid action provided. Valid options are: 'user' or 'position'.")

        return action_requested

    except Exception as e:
        logging.exception("Failed to decode or parse Pub/Sub data: %s", e)
        raise


def load_config():
    sql_port = fetch_secret("SQL_PORT").strip()
    ias_base_url = fetch_secret("IAS_BASE_URL").strip().rstrip("/")

    return {
        # IAS / OIDC
        "ias_base_url": ias_base_url,
        "ias_token_endpoint": f"{ias_base_url}/oauth2/token",
        "oidc_client_id": fetch_secret("OIDC_CLIENT_ID").strip(),
        "oidc_client_secret": fetch_secret("OIDC_CLIENT_SECRET").strip(),
        "oidc_dep_name": fetch_secret("OIDC_DEP_NAME").strip(),
        "ias_user_name": fetch_secret("IAS_USER_NAME").strip(),
        "ias_user_password": fetch_secret("IAS_USER_PASSWORD"),

        # Cloud SQL
        "sql_host": fetch_secret("SQL_IP").strip(),
        "sql_port": int(sql_port),
        "sql_password": fetch_secret("SQL_PASSWORD"),
        "sql_database": fetch_secret("SQL_DATABASE").strip(),
    }


def parse_token_response(response, token_name, context):
    if response.status_code != 200:
        raise RuntimeError(
            f"{context} failed: {response.status_code} {response.text}"
        )

    try:
        response_obj = response.json()
    except ValueError as e:
        raise RuntimeError(f"{context} response was not valid JSON: {response.text}") from e

    token = response_obj.get(token_name)

    if not token:
        raise RuntimeError(f"{token_name} was missing from {context} response.")

    return token


def get_ias_id_token_by_password_grant(
    token_endpoint,
    client_id,
    client_secret,
    user_name,
    user_password,
):
    params = {
        "client_id": client_id,
        "client_secret": client_secret,
        "grant_type": GRANT_TYPE_PASSWORD,
        "username": user_name,
        "password": user_password,
        "scope": OIDC_SCOPE,
    }

    response = requests.post(
        token_endpoint,
        data=params,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=REQUEST_TIMEOUT,
    )

    return parse_token_response(
        response=response,
        token_name="id_token",
        context="IAS ID token request",
    )


def exchange_id_token_for_successfactors_access_token(
    token_endpoint,
    client_id,
    client_secret,
    id_token,
    dependency_name,
):
    params = {
        "client_id": client_id,
        "client_secret": client_secret,
        "grant_type": GRANT_TYPE_JWT_BEARER,
        "assertion": id_token,
        "resource": f"{RESOURCE_PREFIX}{dependency_name}",
    }

    response = requests.post(
        token_endpoint,
        data=params,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        timeout=REQUEST_TIMEOUT,
    )

    return parse_token_response(
        response=response,
        token_name="access_token",
        context="SuccessFactors access token exchange",
    )


def get_successfactors_access_token_by_oidc(config):
    id_token = get_ias_id_token_by_password_grant(
        token_endpoint=config["ias_token_endpoint"],
        client_id=config["oidc_client_id"],
        client_secret=config["oidc_client_secret"],
        user_name=config["ias_user_name"],
        user_password=config["ias_user_password"],
    )

    logging.info("Retrieved IAS ID token.")

    access_token = exchange_id_token_for_successfactors_access_token(
        token_endpoint=config["ias_token_endpoint"],
        client_id=config["oidc_client_id"],
        client_secret=config["oidc_client_secret"],
        id_token=id_token,
        dependency_name=config["oidc_dep_name"],
    )

    logging.info("Exchanged IAS ID token for SuccessFactors access token.")

    return access_token


def get_sfs_data(api_url, access_token):
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Accept": "application/json",
        "Content-Type": "application/json",
    }

    response = requests.get(
        api_url,
        headers=headers,
        timeout=REQUEST_TIMEOUT,
    )

    if response.status_code != 200:
        raise RuntimeError(f"Failed to get SF data: {response.status_code} {response.text}")

    return response.json()


def get_sfs_results(api_url, access_token):
    results = []
    next_url = api_url

    while next_url:
        data = get_sfs_data(next_url, access_token)
        d = data.get("d", {})
        page_results = d.get("results", [])

        if isinstance(page_results, list):
            results.extend(page_results)
        elif page_results:
            results.append(page_results)

        next_url = d.get("__next")

    return results


def append_query(api_url, query):
    separator = "&" if "?" in api_url else "?"
    return f"{api_url}{separator}{query}"


def get_deferred_uri(value):
    if isinstance(value, dict):
        deferred = value.get("__deferred")

        if isinstance(deferred, dict):
            return deferred.get("uri", "")

        return value.get("uri", "")

    if isinstance(value, str) and value.startswith("http"):
        return value

    return ""


def resolve_deferred_field(value, field_name, access_token, cache):
    if value is None:
        return ""

    if isinstance(value, str) and not value.startswith("http"):
        return value

    if isinstance(value, dict):
        direct_value = value.get(field_name)

        if direct_value:
            return direct_value

    uri = get_deferred_uri(value)

    if not uri:
        return ""

    cache_key = f"{uri}|{field_name}"

    if cache_key in cache:
        return cache[cache_key]

    try:
        data = get_sfs_data(
            append_query(uri, f"$select={field_name}&$format=JSON"),
            access_token,
        )
        resolved_value = data.get("d", {}).get(field_name) or ""

    except RuntimeError as e:
        if "404" in str(e):
            resolved_value = ""
        else:
            raise

    cache[cache_key] = resolved_value
    return resolved_value


def normalize_nullable(value):
    if value is None:
        return None

    try:
        if pd.isna(value):
            return None
    except (TypeError, ValueError):
        pass

    return value


def normalize_text(value):
    value = normalize_nullable(value)

    if value is None:
        return None

    if isinstance(value, str):
        text = value.replace("~~~~~\n", "")

        if text == "~~~~~":
            return None

        return text.replace("~~~~~", "")

    if isinstance(value, (dict, list)):
        return json.dumps(value, ensure_ascii=False)

    return str(value)


def normalize_user_status(value):
    value = normalize_nullable(value)

    if value == "f":
        return "inactive"

    if value == "t":
        return "active"

    return value


def decode_photo(value):
    value = normalize_nullable(value)

    if value is None or value == "":
        return None

    if isinstance(value, bytes):
        return value

    if isinstance(value, bytearray):
        return bytes(value)

    if isinstance(value, str):
        text = value.strip()

        if not text:
            return None

        try:
            return base64.b64decode(text, validate=True)
        except Exception:
            return text.encode("utf-8")

    return str(value).encode("utf-8")


def parse_sf_datetime(value):
    value = normalize_nullable(value)

    if not value:
        return 0

    value = str(value)

    try:
        if "(" in value and ")" in value:
            inner = value.split("(", 1)[1].split(")", 1)[0]
            inner = inner.split("+", 1)[0].split("-", 1)[0]
            return int(inner)

        return int(value)

    except Exception:
        return 0


def connect_to_sql(config):
    db_config = {
        "host": config["sql_host"],
        "port": config["sql_port"],
        "user": "root",
        "password": config["sql_password"],
        "database": config["sql_database"],
    }

    return mysql.connector.connect(**db_config)


def process_user_update(cursor, access_token):
    api_url_user = (
        f"{SERVER_URL}/odata/v2/User"
        "?$filter=status in 'active','inactive'"
        "&$select=userId,lastName,firstName,email,status"
        "&$format=JSON"
    )

    api_url_photo = (
        f"{SERVER_URL}/odata/v2/Photo"
        "?$filter=photoType eq '26'"
        "&$select=userId,photo"
        "&$format=JSON"
    )

    users = get_sfs_results(api_url_user, access_token)
    photos = get_sfs_results(api_url_photo, access_token)

    sf_user_df = pd.DataFrame(
        users,
        columns=["userId", "lastName", "firstName", "email", "status"],
    )

    if sf_user_df.empty:
        logging.warning("No SuccessFactors user data was returned.")
        return

    photo_dict = {
        item.get("userId"): item.get("photo")
        for item in photos
        if item.get("userId")
    }

    sf_user_df["status"] = sf_user_df["status"].apply(normalize_user_status)
    sf_user_df["photo"] = sf_user_df["userId"].map(photo_dict)

    for column in ["userId", "lastName", "firstName", "email", "status"]:
        sf_user_df[column] = sf_user_df[column].apply(normalize_text)

    sf_user_df["email"] = sf_user_df["email"].fillna("")
    sf_user_df["photo"] = sf_user_df["photo"].apply(decode_photo)

    sf_user_df = sf_user_df[
        sf_user_df["userId"].notna() & (sf_user_df["userId"].astype(str) != "")
    ]

    if sf_user_df.empty:
        logging.warning("No valid SuccessFactors user data was available after normalization.")
        return

    upsert_query = """
        INSERT INTO sfUsers (
            userId,
            lastName,
            firstName,
            email,
            status,
            photo
        ) VALUES (
            %s,
            %s,
            %s,
            %s,
            %s,
            %s
        )
        ON DUPLICATE KEY UPDATE
            lastName = VALUES(lastName),
            firstName = VALUES(firstName),
            email = VALUES(email),
            status = VALUES(status),
            photo = VALUES(photo)
    """

    values = [
        (
            row.userId,
            row.lastName,
            row.firstName,
            row.email,
            row.status,
            row.photo,
        )
        for row in sf_user_df.itertuples(index=False)
    ]

    cursor.executemany(upsert_query, values)
    logging.info("Upserted %s user rows into SQL.", cursor.rowcount)


def load_db_positions(cursor):
    select_query = """
        SELECT
            code,
            effectiveStatus,
            externalName,
            parentPosition,
            incumbent,
            cust_Goal,
            cust_PlanningandExecution,
            cust_StructureReinforcement,
            cust_SkillandExperience,
            cust_Language,
            cust_Notes,
            lastModifiedDateTime
        FROM sfPositions
    """

    cursor.execute(select_query)
    rows = cursor.fetchall()
    columns = [desc[0] for desc in cursor.description]

    return pd.DataFrame(rows, columns=columns)


def process_position_update(cursor, access_token):
    db_position_df = load_db_positions(cursor)

    position_columns = [
        "code",
        "effectiveStatus",
        "externalName",
        "parentPosition",
        "incumbent",
        "cust_Goal",
        "cust_PlanningandExecution",
        "cust_StructureReinforcement",
        "cust_SkillandExperience",
        "cust_Language",
        "cust_Notes",
        "lastModifiedDateTime",
    ]

    api_url_position = (
        f"{SERVER_URL}/odata/v2/Position"
        "?$filter=effectiveStatus eq 'A'"
        "&$select=code,effectiveStatus,externalName_ja_JP,parentPosition,incumbent,"
        "cust_Goal,cust_PlanningandExecution,cust_StructureReinforcement,"
        "cust_SkillandExperience,cust_Language,cust_Notes,lastModifiedDateTime"
        "&$format=JSON"
    )

    positions = get_sfs_results(api_url_position, access_token)

    if not positions:
        logging.warning("No active SuccessFactors position data was returned.")
        delete_all_positions(cursor, db_position_df)
        return

    for position in positions:
        position["externalName"] = position.get("externalName_ja_JP")
        position["parentPosition"] = (
            get_deferred_uri(position.get("parentPosition"))
            or position.get("parentPosition")
            or ""
        )
        position["incumbent"] = (
            get_deferred_uri(position.get("incumbent"))
            or position.get("incumbent")
            or ""
        )

    position_df = pd.DataFrame(positions)

    for column in position_columns:
        if column not in position_df.columns:
            position_df[column] = None

    position_df = position_df[position_columns]

    for column in [
        "code",
        "effectiveStatus",
        "externalName",
        "cust_Goal",
        "cust_PlanningandExecution",
        "cust_StructureReinforcement",
        "cust_SkillandExperience",
        "cust_Language",
        "cust_Notes",
        "lastModifiedDateTime",
    ]:
        position_df[column] = position_df[column].apply(normalize_text)

    position_df = position_df[
        position_df["code"].notna() & (position_df["code"].astype(str) != "")
    ]

    if position_df.empty:
        logging.warning("No valid SuccessFactors position data was available after normalization.")
        delete_all_positions(cursor, db_position_df)
        return

    if db_position_df.empty:
        db_position_df = pd.DataFrame(columns=position_columns)

    for column in position_columns:
        if column not in db_position_df.columns:
            db_position_df[column] = None

    db_position_df = db_position_df[position_columns]

    if db_position_df.empty:
        delta_position_df = position_df.copy()

    else:
        merged_df = pd.merge(
            db_position_df[["code", "lastModifiedDateTime"]],
            position_df[["code", "lastModifiedDateTime"]],
            on="code",
            suffixes=("_db", "_sf"),
        )

        merged_df["lastModifiedDateTime_db_value"] = merged_df[
            "lastModifiedDateTime_db"
        ].apply(parse_sf_datetime)

        merged_df["lastModifiedDateTime_sf_value"] = merged_df[
            "lastModifiedDateTime_sf"
        ].apply(parse_sf_datetime)

        newer_position_codes = merged_df[
            merged_df["lastModifiedDateTime_db_value"]
            < merged_df["lastModifiedDateTime_sf_value"]
        ]["code"].tolist()

        only_in_position_codes = position_df[
            ~position_df["code"].isin(db_position_df["code"])
        ]["code"].tolist()

        combined_codes = set(newer_position_codes + only_in_position_codes)
        delta_position_df = position_df[position_df["code"].isin(combined_codes)].copy()

    parent_cache = {}
    incumbent_cache = {}

    if not delta_position_df.empty:
        delta_position_df["parentPosition"] = delta_position_df["parentPosition"].apply(
            lambda value: resolve_deferred_field(value, "code", access_token, parent_cache)
        )

        delta_position_df["incumbent"] = delta_position_df["incumbent"].apply(
            lambda value: resolve_deferred_field(value, "userId", access_token, incumbent_cache)
        )

    combined_position_df = pd.concat(
        [
            delta_position_df[position_columns],
            db_position_df[position_columns],
        ],
        ignore_index=True,
    )

    combined_position_df = combined_position_df.drop_duplicates(subset="code", keep="first")

    combined_position_df = combined_position_df[
        combined_position_df["code"].isin(position_df["code"])
    ]

    filtered_positions_df = keep_largest_position_tree(combined_position_df)

    upsert_positions(cursor, filtered_positions_df)

    active_filtered_codes = set(filtered_positions_df["code"].tolist())
    db_codes = set(db_position_df["code"].dropna().tolist())
    delete_codes = db_codes - active_filtered_codes

    if delete_codes:
        cursor.executemany(
            "DELETE FROM sfPositions WHERE code = %s",
            [(code,) for code in delete_codes],
        )

        logging.info("Deleted %s position rows from SQL.", len(delete_codes))


def delete_all_positions(cursor, db_position_df):
    if db_position_df.empty:
        return

    codes = db_position_df["code"].dropna().tolist()

    if not codes:
        return

    cursor.executemany(
        "DELETE FROM sfPositions WHERE code = %s",
        [(code,) for code in codes],
    )

    logging.info("Deleted all %s position rows from SQL.", len(codes))


def keep_largest_position_tree(position_df):
    if position_df.empty:
        return position_df

    graph = nx.DiGraph()

    for row in position_df.itertuples(index=False):
        code = normalize_text(row.code)
        parent_position = normalize_text(row.parentPosition)

        if not code:
            continue

        graph.add_node(code)

        if parent_position:
            graph.add_edge(parent_position, code)

    connected_components = list(nx.weakly_connected_components(graph))

    if not connected_components:
        return position_df.iloc[0:0].copy()

    largest_component = max(connected_components, key=len)

    return position_df[position_df["code"].isin(largest_component)].copy()


def upsert_positions(cursor, position_df):
    if position_df.empty:
        logging.warning("No position rows to upsert after filtering largest position tree.")
        return

    upsert_query = """
        INSERT INTO sfPositions (
            code,
            effectiveStatus,
            externalName,
            parentPosition,
            incumbent,
            cust_Goal,
            cust_PlanningandExecution,
            cust_StructureReinforcement,
            cust_SkillandExperience,
            cust_Language,
            cust_Notes,
            lastModifiedDateTime
        ) VALUES (
            %s,
            %s,
            %s,
            %s,
            %s,
            %s,
            %s,
            %s,
            %s,
            %s,
            %s,
            %s
        )
        ON DUPLICATE KEY UPDATE
            effectiveStatus = VALUES(effectiveStatus),
            externalName = VALUES(externalName),
            parentPosition = VALUES(parentPosition),
            incumbent = VALUES(incumbent),
            cust_Goal = VALUES(cust_Goal),
            cust_PlanningandExecution = VALUES(cust_PlanningandExecution),
            cust_StructureReinforcement = VALUES(cust_StructureReinforcement),
            cust_SkillandExperience = VALUES(cust_SkillandExperience),
            cust_Language = VALUES(cust_Language),
            cust_Notes = VALUES(cust_Notes),
            lastModifiedDateTime = VALUES(lastModifiedDateTime)
    """

    values = [
        (
            normalize_text(row.code),
            normalize_text(row.effectiveStatus),
            normalize_text(row.externalName),
            normalize_text(row.parentPosition),
            normalize_text(row.incumbent),
            normalize_text(row.cust_Goal),
            normalize_text(row.cust_PlanningandExecution),
            normalize_text(row.cust_StructureReinforcement),
            normalize_text(row.cust_SkillandExperience),
            normalize_text(row.cust_Language),
            normalize_text(row.cust_Notes),
            normalize_text(row.lastModifiedDateTime),
        )
        for row in position_df.itertuples(index=False)
    ]

    cursor.executemany(upsert_query, values)
    logging.info("Upserted %s position rows into SQL.", cursor.rowcount)


@cloud_event
def process_pubsub(event):
    action_requested = decode_pubsub_event(event)

    logging.info("Request received for action: %s", action_requested)

    config = load_config()
    logging.info("Retrieved secrets.")

    access_token = get_successfactors_access_token_by_oidc(config)
    logging.info("Retrieved SuccessFactors access token by OIDC.")

    cnx = None
    cursor = None

    try:
        cnx = connect_to_sql(config)
        cursor = cnx.cursor()
        logging.info("Connected to SQL.")

        if action_requested == "user":
            process_user_update(cursor, access_token)

        elif action_requested == "position":
            process_position_update(cursor, access_token)

        cnx.commit()
        logging.info("Committed SQL transaction.")

    except Exception as e:
        if cnx:
            cnx.rollback()
            logging.info("Rolled back SQL transaction.")

        logging.exception("Failed to process action '%s': %s", action_requested, e)
        raise

    finally:
        if cursor:
            cursor.close()

        if cnx:
            cnx.close()

        logging.info("Closed SQL connection.")

    return f"{action_requested} update completed successfully."
requirements.txt
requests==2.31.0
pandas==2.2.2
mysql-connector-python==8.3.0
networkx==3.3
functions-framework==3.*
google-cloud-secret-manager>=2.7.0
  1. 「編集」をクリックし、ランタイムのメモリを「512 MiB」に設定し、タイムアウトを「540」秒に設定する。
  2. 接続上りは「内部トラフィックのみを許可する」、下りは「default: サーバーレスVPCアクセスコネクタ」の「プライベートIPへのリクエストだけをVPCコネクタ経由でルーティングする」を選択し、デプロイする。
  3. Consoleの「Cloud Scheduler」を開く。
  4. 「ジョブの作成」をクリックし、SQLインスタンスの自動起動・停止をスケジュールした時と同様に「15 7 * * 1」と「30 7 * * 1」でジョブを2つ作る。
ユーザー情報用メッセージ
{
  "action": "user"
}
ポジション情報用メッセージ
{
  "action": "position"
}

アプリのコードを作成

アプリのフロントエンドとバックエンドのコードを書きます。仕組みを図示すると以下のような感じです。

Google Cloud上で直接書かずに、ローカルで開発した方がよいです。MySQLとAnacondaがあれば開発できます。アプリをローカルで起動する際は、Anaconda Powershell Prompt等でpython -m flask --app main runを実行し、ブラウザからhttp://localhost:5000で開けます。

フォルダ構成は以下のような感じにします。Favicon.icoはブラウザタブに表示されるアイコンです。

sample-app/
├── app.yaml
├── main.py
├── requirements.txt
├── templates/
│   └── index.html
└── static/
    ├── script.js
    ├── style.css
    └── favicon.ico

下記は最低限のサンプルコードです。Google Cloudにデプロイする準備ができたら、zipファイルにまとめます。

app.yaml
runtime: python311
service: sf-position-chart

env_variables:
  SQL_HOST: "SQL Private IP"  # ローカルであればlocalhost
  SQL_PORT: "3306"
  SQL_DATABASE: "sfDatabase"
  SQL_USER: "SQLユーザー"
  SQL_PASSWORD: "SQLパスワード"

vpc_access_connector:
  name: "projects/ポジション名/locations/asia-northeast2/connectors/vpcコネクタ名"
  egress_setting: private-ranges-only

handlers:
- url: /static
  static_dir: static

- url: /.*
  script: auto
main.py
import base64
import logging
import os

import mysql.connector
from flask import Flask, jsonify, render_template, send_from_directory
from flask_compress import Compress


app = Flask(__name__)
Compress(app)

logging.basicConfig(level=logging.INFO)


def get_required_env(name):
    value = os.environ.get(name)

    if value is None or value.strip() == "":
        raise RuntimeError(f"Required environment variable is missing: {name}")

    return value.strip()


def get_db_config():
    return {
        "host": get_required_env("SQL_HOST"),
        "port": int(os.environ.get("SQL_PORT", "3306")),
        "database": get_required_env("SQL_DATABASE"),
        "user": get_required_env("SQL_USER"),
        "password": get_required_env("SQL_PASSWORD"),
        "connection_timeout": int(os.environ.get("SQL_CONNECTION_TIMEOUT", "10")),
    }


def get_db_connection():
    try:
        conn = mysql.connector.connect(**get_db_config())
        logging.info("App back-end: Database connection successful.")
        return conn
    except Exception as e:
        logging.exception("App back-end: Failed to connect to the database: %s", e)
        raise


def safe_string(value):
    if value is None:
        return ""

    if isinstance(value, bytes):
        return value.decode("utf-8", errors="replace")

    return str(value)


def safe_parent_id(value):
    if value is None or value == "":
        return None

    return str(value)


def safe_user_id(value):
    if value is None or value == "":
        return "None"

    return str(value)


def is_base64_text(value):
    if not value:
        return False

    try:
        base64.b64decode(value, validate=True)
        return True
    except Exception:
        return False


def format_photo(photo):
    if photo is None or photo == "":
        return ""

    if isinstance(photo, memoryview):
        photo = photo.tobytes()

    if isinstance(photo, bytearray):
        photo = bytes(photo)

    if isinstance(photo, bytes):
        try:
            text_value = photo.decode("utf-8").strip()

            if text_value.startswith("data:image/"):
                return text_value

            if is_base64_text(text_value):
                return f"data:image/jpeg;base64,{text_value}"

            encoded_photo = base64.b64encode(photo).decode("utf-8")
            return f"data:image/jpeg;base64,{encoded_photo}"

        except UnicodeDecodeError:
            encoded_photo = base64.b64encode(photo).decode("utf-8")
            return f"data:image/jpeg;base64,{encoded_photo}"

    if isinstance(photo, str):
        text_value = photo.strip()

        if not text_value:
            return ""

        if text_value.startswith("data:image/"):
            return text_value

        if is_base64_text(text_value):
            return f"data:image/jpeg;base64,{text_value}"

        encoded_photo = base64.b64encode(text_value.encode("utf-8")).decode("utf-8")
        return f"data:image/jpeg;base64,{encoded_photo}"

    encoded_photo = base64.b64encode(str(photo).encode("utf-8")).decode("utf-8")
    return f"data:image/jpeg;base64,{encoded_photo}"


def fetch_and_format_position_data():
    conn = None
    cur = None

    try:
        conn = get_db_connection()
        cur = conn.cursor(dictionary=True)

        cur.execute(
            """
            SELECT
                p.code,
                p.parentPosition,
                p.externalName,
                COALESCE(u.firstName, '') AS firstName,
                COALESCE(u.lastName, '') AS lastName,
                u.photo AS photo,
                COALESCE(u.email, '') AS email,
                p.incumbent,
                p.cust_Goal,
                p.cust_PlanningandExecution,
                p.cust_StructureReinforcement,
                p.cust_SkillandExperience,
                p.cust_Language,
                p.cust_Notes
            FROM sfPositions p
            LEFT JOIN sfUsers u
                ON p.incumbent = u.userId
            """
        )

        raw_positions = cur.fetchall()
        formatted_data = []

        for row in raw_positions:
            formatted_data.append(
                {
                    "id": safe_string(row.get("code")),
                    "parentId": safe_parent_id(row.get("parentPosition")),
                    "position": safe_string(row.get("externalName")),
                    "name": safe_string(row.get("firstName")),
                    "lastName": safe_string(row.get("lastName")),
                    "image": format_photo(row.get("photo")),
                    "email": safe_string(row.get("email")),
                    "userId": safe_user_id(row.get("incumbent")),
                    "cust_Goal": safe_string(row.get("cust_Goal")),
                    "cust_PlanningandExecution": safe_string(row.get("cust_PlanningandExecution")),
                    "cust_StructureReinforcement": safe_string(row.get("cust_StructureReinforcement")),
                    "cust_SkillandExperience": safe_string(row.get("cust_SkillandExperience")),
                    "cust_Language": safe_string(row.get("cust_Language")),
                    "cust_Notes": safe_string(row.get("cust_Notes")),
                }
            )

        logging.info(
            "App back-end: Position data fetched and formatted successfully. rows=%s",
            len(formatted_data),
        )

        return formatted_data

    except Exception as e:
        logging.exception(
            "App back-end: Error in fetching or formatting position data: %s",
            e,
        )
        raise

    finally:
        if cur is not None:
            cur.close()

        if conn is not None:
            conn.close()


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/favicon.ico")
def favicon():
    favicon_path = os.path.join(app.root_path, "static", "favicon.ico")

    if not os.path.exists(favicon_path):
        return "", 204

    return send_from_directory(
        os.path.join(app.root_path, "static"),
        "favicon.ico",
        mimetype="image/vnd.microsoft.icon",
    )


@app.route("/org_chart_data")
def org_chart_data():
    try:
        data = fetch_and_format_position_data()
        return jsonify(data)

    except Exception as e:
        logging.exception("App back-end: Failed to serve org chart data: %s", e)
        return jsonify({"error": "Failed to fetch organization chart data."}), 500


@app.route("/healthz")
def healthz():
    return jsonify({"status": "ok"})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")), debug=False)
requirements.txt
Flask==3.0.3
Flask-Compress>=1.15.0,<2.0.0
mysql-connector-python==8.3.0
index.html
<!doctype html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ポジション組織図</title>
  <!-- 外部のライブラリ -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
  <script src="https://d3js.org/d3.v7.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/d3-org-chart@3.1.1"></script>
  <script src="https://cdn.jsdelivr.net/npm/d3-flextree@2.1.2/build/d3-flextree.js"></script>
  <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
  <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
  <!-- 内製のライブラリ -->
  <script src="{{ url_for('static', filename='script.js') }}"></script>
  <link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
  <script>
    function toggleExpand() {
      var button = document.getElementById('toggleExpandButton');
      if (button.textContent === '全部開く') {
        chart.expandAll().fit();
        button.textContent = '全部閉じる';
      } else {
        chart.collapseAll().fit();
        button.textContent = '全部開く';
      }
    }
    function toggleCompact() {
      var button = document.getElementById('toggleCompactButton');
      if (button.textContent === '横レベル揃える') {
        chart.compact(false).render().fit();
        button.textContent = 'コンパクトにする';
      } else {
        chart.compact(true).render().fit();
        button.textContent = '横レベル揃える';
      }
    }
  </script>
</head>
<body>

<!-- 組織図操作ボタン -->
<button id="toggleExpandButton" class="modern-btn" onclick="toggleExpand()">全部開く</button>
<button id="toggleCompactButton" class="modern-btn" onclick="toggleCompact()">横レベル揃える</button>
<button class="modern-btn" onclick="chart.fit()">画面に収める</button>

<!-- ポジション検索ボックス -->
<select class="js-example-basic-single" id="searchPosition"></select>

<!-- 注釈 -->
<span style="margin-left: 10px; font-size: 10px; color: gray;">Beta version 0.1 (Data from SuccessFactors and Job-Us) Contact: successfactors.post@asahigroup-japan.co.jp</span>

<!-- ポジション組織図表示用コンテナ -->
<div class="chart-container"></div>

<!-- ポジション詳細モーダル画面 -->
<div id="myModal" class="modal">
  <div class="modal-content">
    <span class="close">&times;</span>

    <!-- 参照画面 表示項目 -->
    <div class="position-detail">
      <div id="profileImage"></div>
      <div id="incumbent"></div>
      <div id="emailAddress"></div>
      <div id="positionName"></div>
      <p id="custGoal"></p>
      <p id="custPlanningandExecution"></p>
      <p id="custStructureReinforcement"></p>
      <p id="custSkillandExperience"></p>
      <p id="custLanguage"></p>
      <p id="custNotes"></p>
    </div>
  </div>
</div>

</body>
</html>
script.js
function safeText(value) {
    if (value === null || value === undefined || value === "None") {
        return "";
    }
    return String(value);
}

function setupAndConfigureSelect(selectElement, positionData, placeholderText) {
    $(selectElement).empty();

    var placeholderOption = document.createElement('option');
    placeholderOption.value = "";
    placeholderOption.textContent = placeholderText;
    placeholderOption.setAttribute("data-email", "");
    placeholderOption.disabled = true;
    placeholderOption.selected = true;
    selectElement.appendChild(placeholderOption);

    positionData.forEach(function(position) {
        var option = document.createElement('option');
        var positionId = safeText(position.id);
        var positionName = safeText(position.position);
        var lastName = safeText(position.lastName);
        var firstName = safeText(position.name);
        var fullName = [lastName, firstName].filter(Boolean).join(' ');
        var label = [positionName, fullName].filter(Boolean).join(': ');

        option.value = positionId;
        option.textContent = label || positionId;
        option.setAttribute('data-email', safeText(position.email));
        selectElement.appendChild(option);
    });

    $(selectElement).select2({
        matcher: matchCustom,
        placeholder: placeholderText,
        allowClear: true,
        dropdownAutoWidth: true,
        width: 'auto'
    });

    $('.js-example-basic-single').css('visibility', 'visible');
}

function matchCustom(params, data) {
    if ($.trim(params.term) === '') {
        return data;
    }

    var term = params.term.toUpperCase();

    if (safeText(data.text).toUpperCase().indexOf(term) > -1) {
        return data;
    }

    var $option = $(data.element);
    var email = safeText($option.data('email')).toUpperCase();

    if (email.indexOf(term) > -1) {
        return data;
    }

    return null;
}

var chart = null;

document.addEventListener('DOMContentLoaded', function() {
    const searchPosition = document.getElementById('searchPosition');

    const modal = document.getElementById('myModal');
    const closeButton = modal.querySelector('.close');

    const profileImage = document.getElementById('profileImage');
    const incumbent = document.getElementById('incumbent');
    const emailaddress = document.getElementById('emailAddress');
    const positionName = document.getElementById('positionName');
    const custGoal = document.getElementById('custGoal');
    const custPlanningandExecution = document.getElementById('custPlanningandExecution');
    const custStructureReinforcement = document.getElementById('custStructureReinforcement');
    const custSkillandExperience = document.getElementById('custSkillandExperience');
    const custLanguage = document.getElementById('custLanguage');
    const custNotes = document.getElementById('custNotes');

    const defaultImageBase64 = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAeAB4AAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAA8ADwDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/KKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAP/2Q==';

    function hasValidIncumbent(data) {
        var userId = safeText(data.userId).trim();
        return userId !== "";
    }

    function getSafeImageSrc(data) {
        var imageValue = safeText(data.image).trim();

        if (imageValue && imageValue !== "data:image/jpeg;base64,") {
            return imageValue;
        }

        return defaultImageBase64;
    }

    function setText(element, value) {
        element.textContent = safeText(value);
    }

    function setEmail(element, email) {
        var emailValue = safeText(email).trim();
        element.innerHTML = "";

        if (!emailValue) {
            return;
        }

        var link = document.createElement("a");
        link.href = "mailto:" + emailValue;
        link.textContent = emailValue;
        element.appendChild(link);
    }

    let fetchedPositionData = [];

    fetch('/org_chart_data')
        .then(response => response.json())
        .then(data => {
            console.log('Position List:', data);
            fetchedPositionData = data;

            setupAndConfigureSelect(searchPosition, fetchedPositionData, 'ポジションを検索...');

            $(searchPosition).on('select2:select', function(e) {
                if (!chart) {
                    return;
                }

                chart.clearHighlighting();
                var positionId = e.params.data.id;
                chart.setCentered(positionId).setHighlighted(positionId).render();
            });

            $(searchPosition).on('select2:unselect', function() {
                if (!chart) {
                    return;
                }

                chart.clearHighlighting();
            });

            chart = new d3.OrgChart()
                .data(fetchedPositionData)
                .nodeWidth(d => 240)
                .nodeHeight(d => 110)
                .childrenMargin(d => 50)
                .compactMarginBetween(d => 35)
                .compactMarginPair(d => 30)
                .nodeUpdate(function() {
                    d3.select(this).select('.node-rect').attr('stroke', 'none');
                })
                .nodeContent(function(d, i, arr, state) {
                    const imageDiffVert = 25 + 2;
                    let hasIncumbent = hasValidIncumbent(d.data);
                    let imageSrc = getSafeImageSrc(d.data);
                    let backgroundColor = '#E9EEF5';
                    let positionId = safeText(d.data.id);
                    let lastName = safeText(d.data.lastName);
                    let firstName = safeText(d.data.name);
                    let position = safeText(d.data.position);

                    if (positionId.startsWith('JP_AB')) {
                        backgroundColor = '#FFF0C1';
                    } else if (positionId.startsWith('JP_AGS')) {
                        backgroundColor = '#E3F3D1';
                    } else if (positionId.startsWith('JP_ASD')) {
                        backgroundColor = '#C9E5FF';
                    }

                    return `
                        <div class='org-chart-node' style='width:${d.width}px;height:${d.height}px;padding-top:${imageDiffVert - 2}px'>
                          <div class='org-chart-node-content' style="background-color:${backgroundColor};width:${d.width - 2}px;height:${d.height - imageDiffVert}px;border: ${d.data._highlighted || d.data._upToTheRootHighlighted ? '5px solid #E27396"' : '1px solid #E4E2E9"'} >
                            <div class='org-chart-id'>#${positionId}</div>
                            ${hasIncumbent ? `
                                <div class='org-chart-image-container' style="background-color:${backgroundColor};margin-top:${-imageDiffVert - 20}px" ></div>
                                <div style="margin-top:${-imageDiffVert - 18}px;"><img class='org-chart-image' src="${imageSrc}" /></div>
                            ` : ``}
                            <div class='org-chart-name'>${lastName} ${firstName}</div>
                            <div class='org-chart-position'>${position}</div>
                          </div>
                        </div>
                    `;
                })
                .onNodeClick((d, i, arr) => {
                    let hasIncumbent = hasValidIncumbent(d.data);
                    let imageSrc = getSafeImageSrc(d.data);

                    if (hasIncumbent) {
                        console.log('Selected UserID:', d.data.userId);
                        profileImage.innerHTML = `<img src="${imageSrc}" style="border-radius:100px;width:60px;height:60px;" />`;
                        incumbent.innerHTML = `<strong>${safeText(d.data.lastName)} ${safeText(d.data.name)}</strong>`;
                        setEmail(emailaddress, d.data.email);
                    } else {
                        profileImage.innerHTML = '';
                        incumbent.innerHTML = '';
                        emailaddress.innerHTML = '';
                    }

                    setText(positionName, d.data.position);
                    setText(custGoal, d.data.cust_Goal);
                    setText(custPlanningandExecution, d.data.cust_PlanningandExecution);
                    setText(custStructureReinforcement, d.data.cust_StructureReinforcement);
                    setText(custSkillandExperience, d.data.cust_SkillandExperience);
                    setText(custLanguage, d.data.cust_Language);
                    setText(custNotes, d.data.cust_Notes);

                    modal.style.display = "block";
                })
                .container('.chart-container')
                .render();
        })
        .catch(error => {
            console.error('Error fetching organization chart data:', error);
        });

    closeButton.onclick = function() {
        modal.style.display = "none";
    };
});
style.css
body, html {
    height: 100%;
    margin: 0;
    padding: 0;
    overflow: hidden; /* Prevent scrolling */
    font-size: 16px;
}

.modern-btn {
    padding: 8px 8px;
    margin: 5px;
    border: none;
    border-radius: 5px;
    background-color: #0064be;
    color: white;
    cursor: pointer;
    box-shadow: 0 2px 5px rgba(0,0,0,0.2);
    transition: all 0.2s ease-in-out;
}

.modern-btn:hover,
.register:hover {
    background-color: #1d4294;
    box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}

.js-example-basic-single {
    visibility: hidden;
}

.select2-container .select2-selection--single .select2-selection__rendered,
.select2-container .select2-results > .select2-results__options .select2-results__option,
.select2-container .select2-search--dropdown .select2-search__field {
    font-size: 16px;
}

.chart-container {
    height: 100%;
    width: 100%;
}

.svg-chart-container {
    height: 100%;
    width: 100%;
}

.modal {
    display: none; 
    position: fixed; 
    z-index: 10; 
    padding-top: 50px; 
    left: 0;
    top: 0;
    width: 100%; 
    height: 100%; 
    overflow: auto; 
    background-color: rgb(0,0,0); 
    background-color: rgba(0,0,0,0.4); 
}

.modal-content {
    background-color: #fefefe;
    margin: auto;
    padding: 2vh;
    border: 1px solid #888;
    width: 80%;
    border-radius: 15px;
    max-height: 80vh;
    overflow-y: auto;
}

.close {
    color: #aaaaaa;
    float: right;
    font-size: 28px;
    font-weight: bold;
}

.close:hover,
.close:focus {
    color: #000;
    text-decoration: none;
    cursor: pointer;
}

.org-chart-node {
    padding-left: 1px;
    padding-right: 1px;
}

.org-chart-node-content {
    background-color: #FFFFFF;
    margin-left: -1px;
    border-radius: 10px;
}

.org-chart-id {
    display: flex;
    justify-content: flex-end;
    margin-top: 5px;
    margin-right: 8px;
    font-size: 10px;
    color: #716E7B;
}

.org-chart-image-container {
    background-color: #FFFFFF;
    border-radius: 100px;
    width: 50px;
    height: 50px;
    margin-left: 15px;
}

.org-chart-image {
    border-radius: 100px;
    width: 40px;
    height: 40px;
    margin-left: 20px;
}

.org-chart-name {
    font-size: 15px;
    color: #08011E;
    margin-left: 20px;
    margin-top: 5px;
}

.org-chart-position {
    margin-left: 20px;
    margin-top: 3px;
    font-size: 10px;
}

アプリのデプロイ

  1. Google Cloud ConsoleでApp Engineを開く。
  2. 「アプリケーションを作成」をクリックする。
  3. リージョンは「asia-northeast2(大阪)」を選択する。
  4. IDとAPIへのアクセスは「Compute Engine default service account」を選択する。
  5. プログラム言語は「Python」、Environmentは「標準」を選択し、「後で」をクリックする。
  6. VMのSSH画面を開く。
  7. Pythonがインストールされていることを確認する。
    sudo apt update && sudo apt upgrade -y && sudo apt install unzip -y
    python3 --version
  8. SSH画面右上の「ファイルをアップロード」から、アプリのコードが入ったzipファイルをアップロードする。
  9. zipファイルを解凍し、zipファイルは削除する。
    unzip sample_app.zip
    rm sample_app.zip
  10. sample_appのディレクトリに移動し、App Engineにデプロイする。
    cd sample_app
    gcloud app deploy

IAPの設定

アプリへのアクセス制御をする機能です。後で会社のアイデンティティプロバイダーと繋げます。

  1. Google Cloud ConsoleでセキュリティのIdentity-Aware Proxyを開く。
  2. 本番のプロジェクトを選択する。
  3. OAuth同意画面を適当に「外部」で作成する。使うことはないが、作成しないと次に進めない。
  4. アプリ一覧でIAPトグルボタンをクリックしてIAPを有効化する。
  5. アプリを選択し、右側の「プリンシパルを追加」をクリックし、「新しいプリンシパル」にテストユーザーのGoogleアカウントに紐づいているメールアドレスを入力し、「IAP-secured Web App User」ロールを選択し、保存をクリックする。
  6. ConsoleでCloud Run Functionsを開く。
  7. 「iap-gcip-hosted-ui-app-engine-app」を選択する。
  8. セキュリティの「allUsersのアクセス権を削除」をクリックし、認証で「公開アクセスを許可する」を選択し、保存する。(公開するのはアプリの入り口の認証サービスだけであり、アプリ事態を認証なしで一般公開しているわけではない)
  9. アプリのURLを開き、Googleアカウントのテストユーザーでアプリを開けることを確認する。

IdPとのSSOの設定

ユーザー認証用のアイデンティティプロバイダーをIAPにつなげます。
下記は、Entra IDと連携されているSAP CISを活用した例です。

  1. SAP CIS管理画面から、Application & ResourcesのApplicationを開く。
  2. 「Create」から新しいアプリをOpenID Connectタイプで作成する。
  3. このアプリのClient AuthenticationにあるClient IDをメモしておく。
  4. Secretsの「Add」をクリックし、OpenIDにチェックを入れ、Secretをメモしておく。
  5. CISのURLに/.well-known/openid-configurationを足して開き、Issuerをメモしておく。
  6. Google Cloud ConsoleでIdentity Platformのプロバイダを開く。
  7. 「OICDのプロバイダを追加」をクリックし、任意のコードフローと名前を入力し、クライアントIDにメモしたClient IDを入力し、発行元はメモしたIssuerを入力し、クライアントシークレットにメモしたSecretを入力し保存する。
  8. 左側の設定から「セキュリティ」をクリックし、ドメインの追加」から組織図アプリのURLを追加する。(冒頭のhttps://と最後の/は除く)
  9. ConsoleのIdentity-Aware Proxyでアプリを選択し、右側の「承認には外部IDを使用します」の開始をクリックし、追加したプロバイダにチェックを入れ、「ログインページを作成」と「asia-northeast2」を選択し、保存をクリックする。
  10. 「このURLを、ゆっこうなredirect_uri(OAuthプロバイダの場合)や有効なACS URL(SAMLプロバイダの場合)として追加します」に表示されているURLをメモしておく。
  11. SAP CIS管理画面から、Application & ResourcesのApplicationで作成したアプリを開き、OpenID Connect Configurationを開き、「Redirect URIs」にメモしたURLを貼り付け、末尾に/__/auth/handlerを付け加える。
  12. アプリのURLを開き、SSOでアプリを開けることを確認する。