営業目標達成ダッシュボードの反省:レポートは動く、だがダッシュボードは動かない
当時の私は、営業部から強く求められていた「月別営業目標達成率ダッシュボード」の構築に意気揚々と取り組んでいました。今思えば、あれが私のSalesforce管理者としての最初の大きな“やらかし”でした。
何が起きたか。最初の数週間、私や一部のテストユーザーが利用する分には、確かにダッシュボードは機能していました。しかし、全営業部員に公開された途端、「表示が遅い」「全然表示されない」「エラーになる」といった問い合わせが殺到し、私のデスクは戦場と化しました。
当時の判断と設計
具体的な要件はこうでした。「カスタムオブジェクトで管理されている月別目標と、商談オブジェクトの実績(成立商談)を突き合わせ、月ごとの達成率をリアルタイムで表示する」。
当時私が取ったアプローチは、非常にストレートなものでした。
- カスタムレポートタイプ: 「目標(主オブジェクト)と商談(関連オブジェクト)」というカスタムレポートタイプを作成しました。
- リレーション: 目標オブジェクトと商談オブジェクトは、目標の期間(例:2023年1月)と商談の完了日(例:2023/01/15)を紐付けるようなロジックはありませんでした。そのため、レポートで「目標期間内に完了した商談」を集計する形を取りました。これは、カスタムレポートタイプにリレーションとして定義できるものではなく、レポートのフィルターや集計項目でなんとかしようとしたのです。
- レポートの集計項目:
- 目標金額:目標オブジェクトから直接取得。
- 達成金額:商談オブジェクトの「金額」項目を集計(SUM)。
- 達成率:達成金額 ÷ 目標金額 * 100 という数式項目をレポートに作成。
- ダッシュボードコンポーネント: このレポートを元に、月別の積み上げ棒グラフやゲージを作成。
当時は、「レポートで表現できるなら、ダッシュボードでもいけるだろう」と安易に考えていました。レポートプレビューでは確かにデータが出ていたし、時間もそれほどかからなかった(と思われた)からです。
なぜ失敗したか(後から分かったこと)
問題の根源は、カスタムレポートタイプとダッシュボードの実行環境の特性を理解していなかったことにありました。
ダッシュボードは、表示されるたびに基になるレポートを「再実行」します。そして、私の作ったレポートは、実質的に非常に複雑なクロスオブジェクト集計を、その場で動的に行っていたのです。
- 動的な結合処理: 目標オブジェクトと商談オブジェクトは、直接的なルックアップリレーションで期間が紐づいているわけではありませんでした。レポートのフィルターや集計項目の数式で、期間ベースの紐付けを「擬似的に」行っていたため、Salesforceの裏側では都度大量のレコードをスキャンし、フィルター条件に合致する商談を探し出して集計するという非常に重い処理が走っていました。
- データ量の増加: 最初は数ヶ月分のテストデータでしたが、営業が日々商談を更新し、月を追うごとにデータ量が増えていきました。数千、数万といった商談レコードを毎回スキャンして集計するのは、あっという間にパフォーマンスの限界を超えました。
- Governor Limits: レポートの実行にはタイムアウトがあります(⚠️ 公式ドキュメント確認が必要ですが、確か数分単位だったはずです)。データ量が増え、処理が重くなると、このタイムアウトに引っかかるようになりました。
- 同時アクセス: 全営業部員が毎朝ダッシュボードを開くため、一度に数十、数百といったリクエストが同時に発生しました。これにより、システム全体の負荷が増大し、他のSalesforce機能にも影響が出始めました。
結局、このダッシュボードは実用に耐えず、ユーザーからは不満の声が上がり続けました。結果として、ゼロから設計し直すことになり、私の評価も一時的に大きく下がったことを覚えています。
今なら別の選択をする
この経験を通じて、ダッシュボードのパフォーマンスを確保するためには、レポートではなくデータモデルレベルでの最適化が不可欠だと痛感しました。
- 集計項目(Roll-up Summary Field)の活用:
「目標」オブジェクトに、その目標期間に紐づく「成立商談の合計金額」を集計する項目を事前に持たせておくべきでした。具体的には、商談オブジェクトに「達成目標(Lookup(目標))」のような項目を追加し、成立時にその商談がどの目標に紐づくかを明示的に関連付けます。そして、目標オブジェクト側でそのルックアップリレーションを使った集計項目を作成します。
// (Salesforce上で設定する項目なので、コードはイメージです) // Objective__cオブジェクトに作成する集計項目 (Roll-up Summary Field) // Field Label: Achieved Amount // Field Name: Achieved_Amount__c // Roll-up Type: SUM // Summarized Object: Opportunity // Relationship Field: Target_Objective__c (Opportunityのルックアップ項目) // Field to Aggregate: Amount // Criteria: StageName = 'Closed Won'
これにより、ダッシュボードは「事前に計算された集計値」を参照するだけで済むため、実行時の負荷が劇的に軽減されます。
- 非同期処理によるデータ準備:
もし集計項目で対応できない複雑なロジックが必要な場合は、Apex BatchやFlowのスケジュールトリガーを利用して、夜間バッチで実績データをカスタムオブジェクト(例:
Monthly_Sales_Summary__c)に集計・書き込んでおくべきでした。ダッシュボードはその「集計済みデータ」を見るようにすれば、パフォーマンス問題は回避できます。// (Apex Batchのイメージ) // 目標オブジェクトと商談オブジェクトを結合し、月次集計を行う global class MonthlySalesSummaryBatch implements Database.Batchable<sObject> { global Database.QueryLocator start(Database.BatchableContext BC) { return Database.getQueryLocator('SELECT Id, Name, StartDate__c, EndDate__c FROM Monthly_Objective__c'); } global void execute(Database.BatchableContext BC, List<Monthly_Objective__c> scope) { List<Monthly_Sales_Summary__c> summariesToInsert = new List<Monthly_Sales_Summary__c>(); for (Monthly_Objective__c obj : scope) { Decimal totalAchievedAmount = 0; // DML limit, SOQL limit に注意し、効率的なクエリに改善が必要 for (Opportunity opp : [SELECT Amount FROM Opportunity WHERE CloseDate >= :obj.StartDate__c AND CloseDate <= :obj.EndDate__c AND StageName = 'Closed Won']) { totalAchievedAmount += opp.Amount; } summariesToInsert.add(new Monthly_Sales_Summary__c( Objective__c = obj.Id, Achieved_Amount__c = totalAchievedAmount, Summary_Month__c = obj.StartDate__c.month() // 月の特定 // その他の集計項目 )); } insert summariesToInsert; } global void finish(Database.BatchableContext BC) { // 後処理 } } - シンプルなレポートタイプ: ベースとなるレポートタイプは、できるだけ単純な親子関係で、結合条件が明確なものを選ぶべきでした。クロスオブジェクトレポートでも、直接的なリレーションを辿る形であればまだしも、カスタムレポートタイプで複雑な関係性を擬似的に再現しようとするのは危険です。
この一件以来、私はダッシュボードの要件を聞く際、「そのデータ、どこにどういう形で存在していますか?」「もしリレーションがないなら、どうやって紐付けますか?」という質問を必ず、しつこいほど聞くようになりました。そして、可能な限り事前に集計済みの項目を使うか、専用の集計用カスタムオブジェクトを用意する方針を取っています。
当時の自分に言いたい。「レポートプレビューで動くからといって、そのまま本番リリースするな!」と。
これは当時の自分向けのメモだ。
コメント
コメントを投稿