エンジニアの藤野です。
Salesforceの主要な機能の一つであるレポートは、Apexから実行することもできます。 公式のドキュメントとしては下記のものなどがあります。
この記事では一例として、レポートの実行結果をCSVとして出力するコードを実装し、解説します。
実行環境は下記のとおりです。
- macOS Mojave version10.14.6
- Google Chrome Version 78.0.3904.97 (Official Build) (64-bit)
- Salesforce Developer Edition (Lightning Experience) API Version 47.0
使用するレポート
レポートの題材として、Trailheadのレポートの形式設定の「マトリックスレポート」で作成している「Revenue Trend by Type」レポートを使用します。
これは行、列ともにレベル1のグルーピングを行うマトリックスレポートです。
ゴールと出力例
適当なレコードを作成し、「Revenue Trend by Type」レポートを実行しました。
これに対して、ゴールは下記のCSVを出力することです。 CSVファイル名はレポート名をそのまま使うものとします。 また、詳細は出力しません。
Revenue+Trend+by+Type.csv
:
完了予定月,種別,-,Existing Customer - Upgrade,Existing Customer - Replacement,Existing Customer - Downgrade,New Customer,合計 2019/01/01,金額 合計:,0.0,0.0,0.0,0.0,1.0E7,1.0E7 2019/02/01,金額 合計:,0.0,0.0,0.0,2000000.0,0.0,2000000.0 2019/03/01,金額 合計:,880000.0,0.0,0.0,0.0,0.0,880000.0 2019/05/01,金額 合計:,0.0,0.0,5000000.0,0.0,0.0,5000000.0 2019/07/01,金額 合計:,0.0,0.0,0.0,0.0,0.0,0.0 2019/08/01,金額 合計:,0.0,0.0,0.0,100000.0,0.0,100000.0 2019/09/01,金額 合計:,0.0,2.0E7,0.0,0.0,0.0,2.0E7 合計,金額 合計:,880000.0,2.0E7,5000000.0,2100000.0,1.0E7,3.798E7
また、このCSVはExcelでそのまま表示できるものとします。
Excelで表示した状態:
実装の構成
CSVの出力にはVisualforce Pageを使う方法を採用しました。(参考)
今回は下記3コンポーネントで構成しています。
- Report2CsvDownload.vfp (入口となるVisualforceページ)
- Report2CsvDownloadController.apxc (カスタムコントローラ)
- Report2Csv.apxc (レポートの実行、CSV化を行うクラス)
各コンポーネントの関係は下記のようになっています。
なお、Report2CsvDownload.vfp
はクエリパラメータにレポートのIDを受け取ることを想定しています。
例えば、下記のように生成されたリンクから遷移します。
public PageReference download(reportId) { PageReference pr = new PageReference('/apex/Report2CsvDownload'); pr.getParameters().put('id',reportId); pr.setRedirect(false); return pr; }
実装
実際のコードは下記のとおりです。
Report2CsvDownload.vfp
<apex:page controller="Report2CsvDownloadController" cache="true" contentType="text/csv;charset=Shift-JIS" readOnly="true"> <apex:repeat value="{!rows}" var="row"> <apex:outputText value="{!row}" /> </apex:repeat> </apex:page>
rows
変数に格納されているコレクションを出力します。
今回はStringのListを想定しています。
また、Excelで表示できるCSVとするためにcontentType="text/csv;charset=Shift-JIS"
を指定します。
Report2CsvDownloadController.apxc
public class Report2CsvDownloadController { public List<String> rows { get; set; } public Report2CsvDownloadController() { String reportId = apexpages.currentpage().getparameters().get('id'); Report2Csv runner = new Report2Csv(reportId); String reportName = runner.metadata.getName(); String encoded = EncodingUtil.urlEncode(reportName + '.csv', 'UTF-8'); ApexPages.currentPage().getHeaders().put('Content-Disposition', 'attachment; filename=' + encoded); List<String> rows = runner.generate(); this.rows = rows; } }
ページのパラメータから取得したレポートIDを使ってReport2Csv
のインスタンスを作成し、CSC(StringのList)を取得します。
また、レポートメタデータからレポート名を取得し、出力に使用しています。
Report2Csv.apxc
コードを展開
public with sharing class Report2Csv { public Reports.ReportMetadata metadata; // レポート集計項目の定義 public Reports.ReportResults results; public Reports.ReportExtendedMetadata extendedMetadata; // レポート集計項目の、実際に集計されたレコードのラベル public Report2Csv(String reportId) { this.metadata = Reports.ReportManager.describeReport(reportId).getReportMetadata(); this.results = Reports.ReportManager.runReport(reportId, metadata, false); this.extendedMetadata = results.getReportExtendedMetadata(); } public List<String> generate() { List<List<String>> matrix = new List<List<String>>(); matrix.addAll(makeHeader()); matrix.addAll(makeMatrix()); return matrixToRow(escapeMatrix(matrix)); } // 見出し部 public List<List<String>> makeHeader() { List<List<String>> matrix = new List<List<String>>(); List<String> row = new List<String>(); // 行グルーピング List<Reports.GroupingInfo> groupingDown = this.metadata.getGroupingsDown(); for(Reports.GroupingInfo grouping : groupingDown) { String groupingName = grouping.getName(); Reports.GroupingColumn column = this.extendedMetadata.getGroupingColumnInfo().get(groupingName); row.add(column.getLabel()); } // 列グルーピング List<Reports.GroupingInfo> groupingsAcross = this.metadata.getGroupingsAcross(); for(Reports.GroupingInfo grouping : groupingsAcross) { String groupingName = grouping.getName(); Reports.GroupingColumn column = this.extendedMetadata.getGroupingColumnInfo().get(groupingName); row.add(column.getLabel()); } for(Reports.GroupingValue colmunGroupingValue: this.results.getGroupingsAcross().getGroupings()) { row.add(colmunGroupingValue.getLabel()); } row.add('合計'); matrix.add(row); return matrix; } public List<List<String>> makeMatrix() { List<List<String>> matrix = new List<List<String>>(); List<Reports.GroupingValue> rowGroupings = this.results.getGroupingsDown().getGroupings(); for(Reports.GroupingValue rowGrouping : rowGroupings) { String rowKey = rowGrouping.getKey(); for(Integer i = 0; i < this.metadata.getAggregates().size(); i++) { List<String> row = new List<String>(); if(i == 0) { row.add(rowGrouping.getLabel()); } else { row.add(''); } String aggregateKey= this.metadata.getAggregates()[i]; row.add(this.extendedMetadata.getAggregateColumnInfo().get(aggregateKey).getLabel()); List<Reports.GroupingValue> columnGroupings = this.results.getGroupingsAcross().getGroupings(); for(Integer j = 0; j < columnGroupings.size() + 1; j++) { String colmunKey; if(j == columnGroupings.size()) { // 総計列 colmunKey = 'T'; } else { colmunKey = columnGroupings[j].getKey(); } String factKey = rowKey + '!' + colmunKey; Reports.ReportFact rf = this.results.getFactMap().get(factKey); List<Reports.SummaryValue> summaryValues = rf.getAggregates(); String summaryValue = formatSummaryValue(summaryValues[i]); row.add(summaryValue); } matrix.add(row); } } // 総計行 for(Integer i = 0; i < this.metadata.getAggregates().size(); i++) { List<String> row = new List<String>(); if(i == 0){ row.add('合計'); } else { row.add(''); } String aggregateKey= this.metadata.getAggregates()[i]; row.add(this.extendedMetadata.getAggregateColumnInfo().get(aggregateKey).getLabel()); List<Reports.GroupingValue> columnGroupings = this.results.getGroupingsAcross().getGroupings(); for(Integer j = 0; j < columnGroupings.size() + 1; j++) { String colmunKey; if(j == columnGroupings.size()) { // 総計列 colmunKey = 'T'; } else { colmunKey = columnGroupings[j].getKey(); } String factKey = 'T!' + colmunKey; Reports.ReportFact rf = this.results.getFactMap().get(factKey); List<Reports.SummaryValue> summaryValues = rf.getAggregates(); String summaryValue = formatSummaryValue(summaryValues[i]); row.add(summaryValue); } matrix.add(row); } return matrix; } // Reports.SummaryValue インスタンスをStringに変換する private String formatSummaryValue(Reports.SummaryValue summaryValue) { String ret; if(summaryValue == null) { return ''; } if(summaryValue.getValue() instanceof Double) { Double rounded = Math.roundToLong(Double.valueOf(summaryValue.getValue()) * 100) / 100; ret = String.valueOf(rounded); } else { ret = String.valueOf(summaryValue.getValue()); } return ret; } // CSV出力のため、値をエスケープする private List<List<String>> escapeMatrix(List<List<String>> matrix) { List<List<String>> escapedMatrix = new List<LIst<String>>(); for(List<String> row : matrix) { List<String> escapedRow = new List<String>(); for(String value: row) { if(value == null){ escapedRow.add(''); } else { escapedRow.add(value.escapeCsv()); } } escapedMatrix.add(escapedRow); } return escapedMatrix; } // リストのリストを、Stringのリストにする private List<String> matrixToRow(List<List<String>> matrix) { List<String> joinedRows = new List<String>(); for(List<String> row : matrix) { String joinedRow = String.join(row, ',') + '\n'; joinedRows.add(joinedRow); } return joinedRows; } }
コードが長いため、メソッドごとに解説します。
コンストラクタ
public Report2Csv(String reportId) { this.metadata = Reports.ReportManager.describeReport(reportId).getReportMetadata(); this.results = Reports.ReportManager.runReport(reportId, metadata, false); this.extendedMetadata = results.getReportExtendedMetadata(); }
レポートIDを受け取り、レポートを実行します。
this.metadata = Reports.ReportManager.describeReport(reportId).getReportMetadata();
レポートIDからReportMetadataを取得します。 ReportMetadataは大まかに言うと、レポート実行前に取得可能なレポートの定義・絞り込み条件などの情報が収められています。
this.results = Reports.ReportManager.runReport(reportId, metadata, false);
レポートを実行します。
第3引数のincludeDetails
にfalse
を指定して詳細を出力しないようにしています。
this.extendedMetadata = results.getReportExtendedMetadata();
実行結果からReportExtendedMetadataを取得します。
ReportExtendedMetadata
はReportMetadata
に対して、レポート実行後の情報を持っています。
今回は主に、グルーピング項目の実行後の値を取得するために使います。
generateメソッド
public List<String> generate() { List<List<String>> matrix = new List<List<String>>(); matrix.addAll(makeHeader()); matrix.addAll(makeMatrix()); return matrixToRow(escapeMatrix(matrix)); }
レポート実行結果からCSVを組み立てます。
makeHeader
メソッドで見出し部分を、makeMatrix
メソッドで値を行列として組み立て、matrixToRowでCSVの形式にします。
makeHeaderメソッド
// 見出し部 public List<List<String>> makeHeader() { List<List<String>> matrix = new List<List<String>>(); List<String> row = new List<String>(); // 行グルーピング List<Reports.GroupingInfo> groupingDown = this.metadata.getGroupingsDown(); for(Reports.GroupingInfo grouping : groupingDown) { String groupingName = grouping.getName(); Reports.GroupingColumn column = this.extendedMetadata.getGroupingColumnInfo().get(groupingName); row.add(column.getLabel()); } // 列グルーピング List<Reports.GroupingInfo> groupingsAcross = this.metadata.getGroupingsAcross(); for(Reports.GroupingInfo grouping : groupingsAcross) { String groupingName = grouping.getName(); Reports.GroupingColumn column = this.extendedMetadata.getGroupingColumnInfo().get(groupingName); row.add(column.getLabel()); } for(Reports.GroupingValue colmunGroupingValue: this.results.getGroupingsAcross().getGroupings()) { row.add(colmunGroupingValue.getLabel()); } row.add('合計'); matrix.add(row); return matrix; }
見出し部分、つまり下記の部分を取得します。
// 行グルーピング List<Reports.GroupingInfo> groupingDown = this.metadata.getGroupingsDown(); for(Reports.GroupingInfo grouping : groupingDown) { String groupingName = grouping.getName(); Reports.GroupingColumn column = this.extendedMetadata.getGroupingColumnInfo().get(groupingName); row.add(column.getLabel()); }
ここで取得するのは行グルーピングの表示ラベルです。
ほしいのはローカライズされた表示名であり、これはReportExtendedMetadataクラスのgetDetailColumnInfoメソッドでとして取得できます。 ただし、これは{グルーピングに使用した項目のAPI名:GroupingColumnクラスのインスタンス}のMapになっており、行・列両方のグルーピング情報が入っています。
例:
{CLOSE_MONTH=Reports.GroupingColumn[dataType=DATE_DATA, groupingLevel=0, label=完了予定月, name=CLOSE_MONTH], TYPE=Reports.GroupingColumn[dataType=PICKLIST_DATA, groupingLevel=0, label=種別, name=TYPE]}
ここから行グルーピングの表示ラベルを取得するには項目のAPI名が必要です。 そのため、ReportMetadataクラスのgetGroupingsDownメソッドで行グルーピングの設定を取得しています。
列グルーピングも同様です。
// 列グルーピング List<Reports.GroupingInfo> groupingsAcross = this.metadata.getGroupingsAcross(); for(Reports.GroupingInfo grouping : groupingsAcross) { String groupingName = grouping.getName(); Reports.GroupingColumn column = this.extendedMetadata.getGroupingColumnInfo().get(groupingName); row.add(column.getLabel()); }
ReportMetadataクラスのgetGroupingsAcrossメソッドで列グルーピングの設定を取得し、その後、表示ラベルを取得します。
次に、実際に列のグルーピングに使われた値の表示ラベルを取得します。
for(Reports.GroupingValue colmunGroupingValue: this.results.getGroupingsAcross().getGroupings()) { row.add(colmunGroupingValue.getLabel()); }
総計列は固定値でラベルを追加しています。ここもローカライズされているので取得する方法がありそうなのですが、今回は見つけられませんでした。
row.add('合計');
makeMatrixメソッド
レポートの行グルーピングラベルおよび、レコードデータ、総計列、総計行を埋めます。
コードを展開
public List<List<String>> makeMatrix() { List<List<String>> matrix = new List<List<String>>(); List<Reports.GroupingValue> rowGroupings = this.results.getGroupingsDown().getGroupings(); for(Reports.GroupingValue rowGrouping : rowGroupings) { String rowKey = rowGrouping.getKey(); for(Integer i = 0; i < this.metadata.getAggregates().size(); i++) { List<String> row = new List<String>(); if(i == 0) { row.add(rowGrouping.getLabel()); } else { row.add(''); } String aggregateKey= this.metadata.getAggregates()[i]; row.add(this.extendedMetadata.getAggregateColumnInfo().get(aggregateKey).getLabel()); List<Reports.GroupingValue> columnGroupings = this.results.getGroupingsAcross().getGroupings(); for(Integer j = 0; j < columnGroupings.size() + 1; j++) { String colmunKey; if(j == columnGroupings.size()) { // 総計列 colmunKey = 'T'; } else { colmunKey = columnGroupings[j].getKey(); } String factKey = rowKey + '!' + colmunKey; Reports.ReportFact rf = this.results.getFactMap().get(factKey); List<Reports.SummaryValue> summaryValues = rf.getAggregates(); String summaryValue = formatSummaryValue(summaryValues[i]); row.add(summaryValue); } matrix.add(row); } } // 総計行 for(Integer i = 0; i < this.metadata.getAggregates().size(); i++) { List<String> row = new List<String>(); if(i == 0){ row.add('合計'); } else { row.add(''); } String aggregateKey= this.metadata.getAggregates()[i]; row.add(this.extendedMetadata.getAggregateColumnInfo().get(aggregateKey).getLabel()); List<Reports.GroupingValue> columnGroupings = this.results.getGroupingsAcross().getGroupings(); for(Integer j = 0; j < columnGroupings.size() + 1; j++) { String colmunKey; if(j == columnGroupings.size()) { // 総計列 colmunKey = 'T'; } else { colmunKey = columnGroupings[j].getKey(); } String factKey = 'T!' + colmunKey; Reports.ReportFact rf = this.results.getFactMap().get(factKey); List<Reports.SummaryValue> summaryValues = rf.getAggregates(); String summaryValue = formatSummaryValue(summaryValues[i]); row.add(summaryValue); } matrix.add(row); } return matrix; }
レポートのデータおよび総計はファクトマップという形式で扱います。
マトリックスレポートでは0始まりのインデックスを組み合わせてデータを取得します。ただし、総計行および総系列のインデックスはT
です。
List<Reports.GroupingValue> rowGroupings = this.results.getGroupingsDown().getGroupings();
ReportResultsクラスのgetGroupingsDownメソッドから行グルーピングの情報を取得します。 具体的には行をグルーピングした値、表示ラベル、ファクトマップで使える行の識別子(キー)などです。 今回のレポートでは下記のような情報が取得できます。
(Reports.GroupingValue[groupings=null, key=0, label=2019/01/01, value=2019-01-01 00:00:00], Reports.GroupingValue[groupings=null, key=1, label=2019/02/01, value=2019-02-01 00:00:00], ...
それでは、行ごとの処理に入っていきます。
for(Integer i = 0; i < this.metadata.getAggregates().size(); i++) { List<String> row = new List<String>(); if(i == 0) { row.add(rowGrouping.getLabel()); } else { row.add(''); } String aggregateKey= this.metadata.getAggregates()[i]; row.add(this.extendedMetadata.getAggregateColumnInfo().get(aggregateKey).getLabel());;
ここでは集計項目の表示ラベルを取得します。
行・列グルーピング項目の表示ラベルと同様に、ReportMetadataクラスにAPI項目名が、ReportExtendedMetadataクラスに表示ラベルが入っています。
イテレーションしている理由は、グルーピングレベルが1より大きい場合getAggregates()
の結果が複数になるためです。今回はレベル1なのでi
は0で固定ですが……。この集計項目のインデックスは、値を表示するときにも必要になります。
List<Reports.GroupingValue> columnGroupings = this.results.getGroupingsAcross().getGroupings(); for(Integer k = 0; k < columnGroupings.size() + 1; k++) { String colmunKey; if(k == columnGroupings.size()) { // 総計列 colmunKey = 'T'; } else { colmunKey = columnGroupings[k].getKey(); } String factKey = rowKey + '!' + colmunKey; Reports.ReportFact rf = this.results.getFactMap().get(factKey); List<Reports.SummaryValue> summaryValues = rf.getAggregates(); String summaryValue = formatSummaryValue(summaryValues[j]); row.add(summaryValue); } matrix.add(row);
列の識別子(キー)も取得し、ファクトマップから値を取得します。
ファクトマップのキーは行のキーと列のキーを!
で連結した形になります。
// 総計行 for(Integer i = 0; i < this.metadata.getAggregates().size(); i++) { List<String> row = new List<String>(); if(i == 0){ row.add('合計'); } else { row.add(''); } String aggregateKey= this.metadata.getAggregates()[i]; row.add(this.extendedMetadata.getAggregateColumnInfo().get(aggregateKey).getLabel()); List<Reports.GroupingValue> columnGroupings = this.results.getGroupingsAcross().getGroupings(); for(Integer j = 0; j < columnGroupings.size() + 1; j++) { String colmunKey; if(j == columnGroupings.size()) { // 総計列 colmunKey = 'T'; } else { colmunKey = columnGroupings[j].getKey(); } String factKey = 'T!' + colmunKey; Reports.ReportFact rf = this.results.getFactMap().get(factKey); List<Reports.SummaryValue> summaryValues = rf.getAggregates(); String summaryValue = formatSummaryValue(summaryValues[i]); row.add(summaryValue); } matrix.add(row);
総計行の値の取得は他の行とほぼ同じです。分岐が複雑になるのを避けるために処理を分けています。が、最終的には処理をまとめたほうがいいと思います。
他のメソッド
コードを展開
// Reports.SummaryValue インスタンスをStringに変換する private String formatSummaryValue(Reports.SummaryValue summaryValue) { String ret; if(summaryValue == null) { return ''; } if(summaryValue.getValue() instanceof Double) { Double rounded = Math.roundToLong(Double.valueOf(summaryValue.getValue()) * 100) / 100; ret = String.valueOf(rounded); } else { ret = String.valueOf(summaryValue.getValue()); } return ret; } // CSV出力のため、値をエスケープする private List<List<String>> escapeMatrix(List<List<String>> matrix) { List<List<String>> escapedMatrix = new List<LIst<String>>(); for(List<String> row : matrix) { List<String> escapedRow = new List<String>(); for(String value: row) { if(value == null){ escapedRow.add(''); } else { escapedRow.add(value.escapeCsv()); } } escapedMatrix.add(escapedRow); } return escapedMatrix; } // リストのリストを、Stringのリストにする private List<String> matrixToRow(List<List<String>> matrix) { List<String> joinedRows = new List<String>(); for(List<String> row : matrix) { String joinedRow = String.join(row, ',') + '\n'; joinedRows.add(joinedRow); } return joinedRows; }
いずれもCSV出力のためにデータを整えるためのメソッドです。
formatSummaryValue
メソッド
Reports.SummaryValue
として取得したレポートの値をStringに変換します。
小数は末尾の余分な0を削除するために四捨五入しています。
escapeMatrix
メソッド
StringクラスのescapeCsvメソッドで2重リストをエスケープします。
matrixToRow
メソッド
VFpageにStringのListで渡すために、二重リストの内側のリストを,
で連結します。
所感
今回紹介した実装は行列1レベルのマトリックス形式以外には対応していません。 実際、他のレポート形式にも対応させたコードも書いてみたのですが、なかなか共通化できず、個別の処理が必要になりました。
本機能でレポート機能を汎用的に使えるようにするのはかなり大変だと思います。 それよりも特定のレポートの値が必要な場合に決め打ちで実行する、という使い方で力を発揮するものだと思いました。
以上です。