Salesforce Nonprofit Cloud のカスタマイズ:LWCによる寄付者ギビングサマリーの構築

背景と適用シナリオ

Salesforce 開発者として、私は日々、クライアントのユニークなビジネス要件を Salesforce プラットフォーム上で実現する責務を負っています。特に、非営利団体 (NPO) のためのソリューションである Nonprofit Cloud は、その使命をサポートするために高度にカスタマイズ可能な強力な基盤を提供します。Nonprofit Cloud の中核をなす Nonprofit Success Pack (NPSP) は、寄付管理、支援者との関係管理など、NPO 特有の業務を標準機能でカバーしていますが、実務では「もう一歩踏み込んだ」機能が求められることが少なくありません。

典型的なシナリオとして、ファンドレイザー(資金調達担当者)が支援者とコンタクトを取る場面を考えてみましょう。彼らが支援者の連絡先レコードを開いたとき、一目でその支援者のエンゲージメントレベルを把握したいと考えるのは自然なことです。「今年の寄付総額はいくらか?」「最後の寄付はいつだったか?」「生涯の寄付総額は?」といった情報は、次のアクションを決定する上で極めて重要です。

NPSP には標準で関連リストや積み上げ集計項目がありますが、複数の情報を統合し、視覚的に分かりやすく表示するには限界があります。例えば、異なる年の寄付額を比較したり、最新の寄付情報をハイライト表示したりといった要求に応えるには、標準コンポーネントだけでは不十分な場合があります。

このような課題を解決するため、本記事では Lightning Web Components (LWC) (ライトニングウェブコンポーネント) と Apex (エイペックス) を活用して、連絡先レコードページにカスタムの「寄付者ギビングサマリー」コンポーネントを開発する方法を、Salesforce 開発者の視点から詳細に解説します。このコンポーネントは、指定された支援者の重要な寄付指標をリアルタイムで集計し、洗練された UI で表示することを目的とします。


原理説明

このカスタムコンポーネントの実現には、フロントエンドの表示を担当する LWC と、バックエンドでデータ処理を行う Apex クラスの連携が不可欠です。全体のアーキテクチャは以下のようになります。

1. データモデルの理解

まず、NPSP のデータモデルを理解することが重要です。NPSP では、個々の寄付は標準オブジェクトであるOpportunity (商談) を利用して記録されます。一般的に、「Donation」などの特定のレコードタイプが割り当てられます。各 Opportunity レコードは、寄付者である Contact (取引先責任者) レコードに紐づいています。

今回取得したい情報は以下の通りです。

  • 生涯寄付総額 (Lifetime Giving): 該当の Contact に関連する、完了したすべての寄付 (Opportunity) の合計金額。
  • 今年の寄付総額 (This Year's Giving): 今年の会計年度内に行われた寄付の合計金額。
  • 昨年の寄付総額 (Last Year's Giving): 昨年の会計年度内に行われた寄付の合計金額。
  • 最新の寄付日 (Last Gift Date): 最後に行われた寄付の日付。

これらの情報を取得するためには、特定の Contact ID に関連付けられた Opportunity レコードを抽出し、日付や金額で集計するロジックが必要になります。

2. Apex コントローラーの役割

LWC は直接 SOQL (Salesforce Object Query Language) (セールスフォースオブジェクトクエリ言語) を実行できないため、サーバーサイドの Apex クラスがデータアクセス層の役割を担います。Apex コントローラーは以下の処理を実行します。

  • LWC から現在のレコードページの Contact ID を受け取ります。
  • 受け取った Contact ID を使って、関連する完了済みの Opportunity (寄付) を検索する SOQL クエリを実行します。
  • SOQL の集計関数 (SUM(), MAX()) と GROUP BY 句を駆使して、必要な指標(生涯寄付総額、今年の寄付総額など)を効率的に計算します。
  • 計算結果を整形し、LWC が扱いやすいデータ構造(例えば、ラッパークラスや Map)で返却します。
メソッドには @AuraEnabled(cacheable=true) アノテーションを付与します。cacheable=true を指定することで、このメソッドは Salesforce のデータ層に対する読み取り専用操作となり、LWC の Wire Service (ワイヤーサービス) から効率的かつ安全に呼び出すことが可能になります。

3. Lightning Web Component (LWC) の役割

LWC はユーザーインターフェース (UI) を担当します。

  • HTML テンプレート: lightning-cardlightning-layout といった基本コンポーネントを使い、サマリー情報を表示するための骨格を定義します。
  • JavaScript コントローラー: @api recordId デコレーターを使って、レコードページから自動的に Contact ID を受け取ります。そして、@wire デコレーターを用いて Apex メソッドを呼び出し、データを取得します。データが正常に取得された場合、またはエラーが発生した場合のロジックを処理します。
  • XML 定義ファイル: コンポーネントのメタデータを定義します。lightning__RecordPage をターゲットとして指定し、連絡先 (Contact) のレコードページに配置できるように設定します。
このアーキテクチャにより、UI とデータロジックが明確に分離され、保守性・再利用性の高いコンポーネントを構築することができます。


示例代码

以下に、寄付者ギビングサマリーコンポーネントを実装するための具体的なコードを示します。このコードは Salesforce の公式ドキュメントで示されているベストプラクティスに基づいています。

1. Apex コントローラー: NPSP_GivingSummaryController.cls

この Apex クラスは、指定された連絡先 ID に基づいて寄付情報を集計し、LWC に返します。

public with sharing class NPSP_GivingSummaryController {
    
    // @AuraEnabled アノテーションにより、LWC からこのメソッドを呼び出すことが可能になる
    // cacheable=true は、サーバーサイドの状態を変更しないデータ取得メソッドであることを示し、
    // クライアントサイドでのキャッシングを有効にしてパフォーマンスを向上させる
    @AuraEnabled(cacheable=true)
    public static Map<String, Object> getGivingSummary(Id contactId) {
        
        // 返却用の Map オブジェクトを初期化
        Map<String, Object> summaryData = new Map<String, Object>{
            'lifetimeTotal' => 0,
            'thisYearTotal' => 0,
            'lastYearTotal' => 0,
            'lastGiftDate' => null
        };
        
        // 集計クエリの結果を格納するリスト
        // AggregateResult は SOQL の集計関数の結果を保持するための汎用 sObject
        List<AggregateResult> results = new List<AggregateResult>();
        
        try {
            // NPSP で「寄付」として扱われる完了した商談を取得する SOQL クエリ
            // 条件:
            // 1. 指定された contactId に紐づいていること
            // 2. IsWon = true (成立した商談)
            // 3. Amount が null でないこと
            //
            // 集計内容:
            // - totalAmount: 生涯寄付総額 (Amount の合計)
            // - lastGift: 最新の寄付日 (CloseDate の最大値)
            results = [
                SELECT SUM(Amount) totalAmount, MAX(CloseDate) lastGift
                FROM Opportunity
                WHERE ContactId = :contactId AND IsWon = true AND Amount != null
            ];

            if (!results.isEmpty() && results[0].get('totalAmount') != null) {
                summaryData.put('lifetimeTotal', (Decimal)results[0].get('totalAmount'));
                summaryData.put('lastGiftDate', (Date)results[0].get('lastGift'));
            }

            // 今年の寄付合計を取得
            AggregateResult thisYearResult = [
                SELECT SUM(Amount) total
                FROM Opportunity
                WHERE ContactId = :contactId AND IsWon = true AND CALENDAR_YEAR(CloseDate) = :Date.today().year()
            ];
            if (thisYearResult.get('total') != null) {
                summaryData.put('thisYearTotal', (Decimal)thisYearResult.get('total'));
            }

            // 昨年の寄付合計を取得
            AggregateResult lastYearResult = [
                SELECT SUM(Amount) total
                FROM Opportunity
                WHERE ContactId = :contactId AND IsWon = true AND CALENDAR_YEAR(CloseDate) = :(Date.today().year() - 1)
            ];
            if (lastYearResult.get('total') != null) {
                summaryData.put('lastYearTotal', (Decimal)lastYearResult.get('total'));
            }

        } catch (Exception e) {
            // AuraHandledException をスローして、LWC 側でエラーを適切に処理できるようにする
            throw new AuraHandledException('An error occurred while querying giving summary: ' + e.getMessage());
        }
        
        return summaryData;
    }
}

2. LWC: givingSummary

この LWC は3つのファイルで構成されます。

givingSummary.html

コンポーネントの見た目を定義する HTML テンプレートです。

<template>
    <lightning-card title="Giving Summary" icon-name="standard:user_role">
        <!-- データがロード中の場合はスピナーを表示 -->
        <template if:true={isLoading}>
            <div class="slds-var-p-around_medium">
                <lightning-spinner alternative-text="Loading..." size="small"></lightning-spinner>
            </div>
        </template>

        <!-- データロード完了後にサマリーを表示 -->
        <template if:false={isLoading}>
            <div class="slds-var-p-around_medium">
                <!-- エラーが発生した場合のメッセージ表示 -->
                <template if:true={error}>
                    <p class="slds-text-color_error">An error occurred: {errorText}</p>
                </template>

                <!-- データが正常に取得できた場合の表示 -->
                <template if:true={summary}>
                    <lightning-layout multiple-rows="true">
                        <lightning-layout-item size="6" padding="around-small">
                            <b>Lifetime Giving:</b>
                            <p>
                                <lightning-formatted-number value={summary.lifetimeTotal} format-style="currency" currency-code="JPY"></lightning-formatted-number>
                            </p>
                        </lightning-layout-item>
                        <lightning-layout-item size="6" padding="around-small">
                            <b>Last Gift Date:</b>
                            <p>
                                <lightning-formatted-date-time value={summary.lastGiftDate}></lightning-formatted-date-time>
                            </p>
                        </lightning-layout-item>
                        <lightning-layout-item size="6" padding="around-small">
                            <b>This Year's Giving:</b>
                            <p>
                                <lightning-formatted-number value={summary.thisYearTotal} format-style="currency" currency-code="JPY"></lightning-formatted-number>
                            </p>
                        </lightning-layout-item>
                        <lightning-layout-item size="6" padding="around-small">
                            <b>Last Year's Giving:</b>
                            <p>
                                <lightning-formatted-number value={summary.lastYearTotal} format-style="currency" currency-code="JPY"></lightning-formatted-number>
                            </p>
                        </lightning-layout-item>
                    </lightning-layout>
                </template>
            </div>
        </template>
    </lightning-card>
</template>
givingSummary.js

コンポーネントのロジックを制御する JavaScript ファイルです。

import { LightningElement, api, wire } from 'lwc';
// Apex メソッドをインポート
import getGivingSummary from '@salesforce/apex/NPSP_GivingSummaryController.getGivingSummary';

export default class GivingSummary extends LightningElement {
    // @api デコレーターにより、このプロパティは公開され、
    // Lightning レコードページから現在のレコードID (Contact ID) を自動的に受け取る
    @api recordId;

    // Apex から取得したサマリーデータを保持するプロパティ
    summary;
    // エラー情報を保持するプロパティ
    error;
    // ローディング状態を管理
    isLoading = true;

    // @wire サービスを使用して Apex メソッドを呼び出す
    // recordId が変更されると、このメソッドは自動的に再実行される
    @wire(getGivingSummary, { contactId: '$recordId' })
    wiredSummary({ error, data }) {
        this.isLoading = true; // 新しいデータ取得が開始されたのでローディング状態にする
        if (data) {
            this.summary = data;
            this.error = undefined;
            this.isLoading = false; // データ取得完了
            console.log('Giving Summary Data:', this.summary);
        } else if (error) {
            this.error = error;
            this.summary = undefined;
            this.isLoading = false; // エラー発生でもローディングは完了
            console.error('Error fetching giving summary:', this.error);
        }
    }
    
    // エラーオブジェクトをユーザーフレンドリーなテキストに変換するゲッター
    get errorText() {
        if (this.error && this.error.body) {
            return this.error.body.message;
        }
        return 'An unknown error occurred.';
    }
}
givingSummary.js-meta.xml

このコンポーネントをどこで使えるかを定義する設定ファイルです。

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>58.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <!-- このコンポーネントをレコードページで利用可能にする -->
        <target>lightning__RecordPage</target>
    </targets>
    <targetConfigs>
        <!-- Contact オブジェクトのレコードページに限定する設定 -->
        <targetConfig targets="lightning__RecordPage">
            <objects>
                <object>Contact</object>
            </objects>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

注意事項

このコンポーネントを実装・運用する際には、いくつかの点に注意が必要です。

権限 (Permissions)

・Apex クラスへのアクセス: このコンポーネントを使用するユーザーのプロファイルまたは権限セットに、NPSP_GivingSummaryController Apex クラスへのアクセス権が付与されていることを確認してください。
・オブジェクトと項目の権限 (FLS): ユーザーは Contact オブジェクトと Opportunity オブジェクトへの参照アクセス権が必要です。また、SOQL でクエリしている項目 (Opportunity.Amount, Opportunity.CloseDate, Opportunity.IsWon, Opportunity.ContactId) に対する参照権限 (Field-Level Security) も必要です。権限が不足している場合、Apex はデータを返せず、コンポーネントにエラーが表示される可能性があります。

API 制限 (API Limits)

今回の実装は比較的シンプルですが、Salesforce のガバナ制限を常に意識することが重要です。 ・SOQL クエリの制限: 1 トランザクション内で発行できる SOQL クエリは 100 回までです。このコンポーネントは 3 回のクエリを実行しますが、同じページに多数のカスタムコンポーネントが配置されたり、複雑なトリガーが実行されたりすると、制限に達する可能性があります。より複雑な要件の場合は、1 回のクエリで全てのデータを取得するようにリファクタリングすることを検討してください(例:GROUP BY CALENDAR_YEAR(CloseDate) を使用する)。
・データ量: 一人の支援者が膨大な数の寄付を行っている場合(数千件以上)、クエリのパフォーマンスに影響が出る可能性があります。必要に応じて、SOQL クエリにインデックス付きの項目を WHERE 句に含めるなどの最適化を検討してください。

エラー処理 (Error Handling)

コード例では基本的なエラーハンドリングを実装しています。Apex 側では try-catch ブロックを使用して予期せぬ例外を捕捉し、AuraHandledException をスローしています。LWC の JavaScript では、@wire サービスの error プロパティをチェックして、UI にエラーメッセージを表示します。実際のプロジェクトでは、より詳細なエラー情報をログに記録したり、ユーザーに分かりやすいメッセージを表示したりするなどの改善が考えられます。

NPSP のデータモデルへの依存

このコードは、寄付が IsWon = trueOpportunity として記録されているという NPSP の標準的なデータモデルを前提としています。もし組織が「支払 (Payment)」オブジェクトを使用して寄付の分割払いを管理している場合や、ソフトクレジットを考慮する必要がある場合は、Apex のロジックを修正し、npe01__OppPayment__c オブジェクトや npsp__Partial_Soft_Credit__c などをクエリに含める必要があります。常に、対象となる組織のデータモデルのカスタマイズ状況を確認することが不可欠です。


まとめとベストプラクティス

本記事では、Salesforce 開発者として Nonprofit Cloud をカスタマイズし、LWC と Apex を用いて「寄付者ギビングサマリー」コンポーネントを構築する方法を解説しました。このコンポーネントは、ファンドレイザーが支援者のエンゲージメントを迅速に把握し、より効果的なコミュニケーションを築くための強力なツールとなります。

この開発を通じて、以下のベストプラクティスを再確認することができました。

  1. 関心の分離 (Separation of Concerns): LWC を UI 層、Apex をデータアクセス層として明確に役割分担させることで、コードの可読性と保守性が向上します。
  2. サーバーサイドの効率化: 必要なデータ集計は、可能な限り Apex 側の SOQL 集計関数で行います。これにより、サーバーとクライアント間のデータ転送量を最小限に抑え、パフォーマンスを最適化できます。
  3. キャッシュの活用: データの読み取り専用操作には @AuraEnabled(cacheable=true) を使用し、LWC のクライアントサイドキャッシュを最大限に活用します。これにより、同じデータを繰り返しリクエストする際のレスポンスが高速化されます。
  4. 動的なパラメータ渡し: @api recordId@wire のリアクティブな性質 ('$recordId') を利用することで、コンポーネントは汎用性を持ち、あらゆる連絡先レコードページで再利用可能になります。
  5. 堅牢なエラーハンドリング: ユーザーエクスペリエンスを損なわないよう、サーバーサイドとクライアントサイドの両方でエラーを適切に捕捉し、ユーザーにフィードバックする仕組みを必ず実装します。

Salesforce Nonprofit Cloud は、そのままでも非常に強力なプラットフォームですが、その真価はビジネスニーズに合わせて柔軟に拡張できる点にあります。開発者は、LWC や Apex といった最新のテクノロジーを駆使して、非営利団体がその使命をより効果的に達成できるよう支援するカスタムソリューションを提供することができます。今回紹介したコンポーネントは、その一例に過ぎません。これを基盤として、さらに機能を追加(例:グラフ表示、目標達成率の表示など)することで、より価値の高いツールへと進化させることが可能です。

コメント