480 lines
19 KiB
OpenEdge ABL
480 lines
19 KiB
OpenEdge ABL
/**
|
|
* ScheduledShopifyReport
|
|
* -------------------------------------------------------------------------
|
|
* Schedulable that runs a Salesforce Report and emails the rendered output
|
|
* with a direct download link for the file in the selected format.
|
|
*
|
|
* FIXED: Uses only valid Salesforce Reports API types. No invalid types
|
|
* like Reports.ReportColumn or getClass() on ReportFact.
|
|
* -------------------------------------------------------------------------
|
|
*/
|
|
public with sharing class ScheduledShopifyReport implements Schedulable {
|
|
|
|
public Id reportId;
|
|
public String reportDevName;
|
|
public String format; // PDF | EXCEL | CSV
|
|
public List<String> recipients;
|
|
|
|
public void execute(SchedulableContext sc) {
|
|
if (reportId == null || recipients == null || recipients.isEmpty()) {
|
|
return;
|
|
}
|
|
try {
|
|
// Run the report WITH detail rows
|
|
Reports.ReportResults rr = Reports.ReportManager.runReport(reportId, true);
|
|
|
|
// DEBUG: Log report structure to verify data
|
|
System.debug('=== ScheduledShopifyReport DEBUG ===');
|
|
System.debug('Report ID: ' + reportId);
|
|
System.debug('Report DevName: ' + reportDevName);
|
|
System.debug('Format: ' + format);
|
|
System.debug('Fact Map Keys: ' + rr.getFactMap().keySet());
|
|
|
|
for (String key : rr.getFactMap().keySet()) {
|
|
Reports.ReportFact fact = rr.getFactMap().get(key);
|
|
System.debug('Fact [' + key + '] aggregates: ' + fact.getAggregates());
|
|
// Use instanceof instead of getClass()
|
|
if (fact instanceof Reports.ReportFactWithDetails) {
|
|
Reports.ReportFactWithDetails d = (Reports.ReportFactWithDetails) fact;
|
|
System.debug('Fact [' + key + '] rows: ' + d.getRows().size());
|
|
}
|
|
}
|
|
System.debug('=====================================');
|
|
|
|
// Generate the file and get download link
|
|
String downloadUrl = generateAndUploadFile(rr);
|
|
|
|
// Build email body with download link
|
|
String body = 'Hi,\n\n' +
|
|
'Your scheduled Shopify Sync report "' + reportDevName + '" ran successfully.\n' +
|
|
'Format: ' + format + '\n\n' +
|
|
'Download your report: ' + downloadUrl + '\n\n' +
|
|
'— Shopify Sync Dashboard\n';
|
|
|
|
Messaging.SingleEmailMessage msg = new Messaging.SingleEmailMessage();
|
|
msg.setToAddresses(recipients);
|
|
msg.setSubject('[Shopify Sync] Scheduled report: ' + reportDevName);
|
|
msg.setPlainTextBody(body);
|
|
|
|
// Also attach the file directly
|
|
Messaging.EmailFileAttachment att = buildReportAttachment(rr);
|
|
if (att != null) {
|
|
msg.setFileAttachments(new List<Messaging.EmailFileAttachment>{ att });
|
|
}
|
|
|
|
// FIX: Messaging.sendEmail() does NOT throw on delivery failure (bad
|
|
// deliverability settings, org email limits, bounced addresses, etc.) —
|
|
// it just returns a result array with success=false. Previously that
|
|
// return value was discarded, so a failed send looked identical to a
|
|
// successful one: the job "ran" but nobody ever got the report.
|
|
List<Messaging.SendEmailResult> results =
|
|
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ msg });
|
|
if (results == null || results.isEmpty() || !results[0].isSuccess()) {
|
|
String sendErr = (results != null && !results.isEmpty() && results[0].getErrors() != null && !results[0].getErrors().isEmpty())
|
|
? results[0].getErrors()[0].getMessage()
|
|
: 'Unknown email delivery failure';
|
|
throw new ScheduledReportException(
|
|
'Report ran but email delivery failed for reportId=' + reportId + ': ' + sendErr);
|
|
}
|
|
|
|
} catch (Exception e) {
|
|
System.debug(LoggingLevel.ERROR,
|
|
'ScheduledShopifyReport failed for reportId=' + reportId + ': ' + e.getMessage());
|
|
System.debug(LoggingLevel.ERROR, 'Stack trace: ' + e.getStackTraceString());
|
|
// FIX: previously the failure died here — nothing left this method,
|
|
// so the schedule owner had no way to know a run had failed short of
|
|
// reading debug logs at the exact moment the job fired. Now we make a
|
|
// best-effort attempt to tell them via email instead of failing silent.
|
|
notifyFailure(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Best-effort failure notification — sent as a SEPARATE email so a failure
|
|
* in report generation/attachment building can't also break this. Any
|
|
* exception here (e.g. deliverability blocked entirely) is swallowed: we've
|
|
* already done everything reasonably possible to surface the problem.
|
|
*/
|
|
@TestVisible
|
|
private void notifyFailure(Exception e) {
|
|
try {
|
|
if (recipients == null || recipients.isEmpty()) return;
|
|
Messaging.SingleEmailMessage errMsg = new Messaging.SingleEmailMessage();
|
|
errMsg.setToAddresses(recipients);
|
|
errMsg.setSubject('[Shopify Sync] Scheduled report FAILED: ' + reportDevName);
|
|
errMsg.setPlainTextBody(
|
|
'Hi,\n\n' +
|
|
'Your scheduled Shopify Sync report "' + reportDevName + '" did not complete successfully.\n' +
|
|
'Reason: ' + e.getMessage() + '\n\n' +
|
|
'Check that the report still exists, is accessible to the user who scheduled it, ' +
|
|
'and that Email Deliverability (Setup) allows sending to external addresses.\n\n' +
|
|
'— Shopify Sync Dashboard\n');
|
|
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ errMsg });
|
|
} catch (Exception notifyEx) {
|
|
System.debug(LoggingLevel.ERROR,
|
|
'ScheduledShopifyReport failure-notification also failed: ' + notifyEx.getMessage());
|
|
}
|
|
}
|
|
|
|
public class ScheduledReportException extends Exception {}
|
|
|
|
// ==================== FILE GENERATION ====================
|
|
|
|
@TestVisible
|
|
private String generateAndUploadFile(Reports.ReportResults rr) {
|
|
try {
|
|
String base64Content = getFileAsBase64(rr);
|
|
if (String.isBlank(base64Content)) {
|
|
return URL.getOrgDomainUrl().toExternalForm() + '/' + reportId;
|
|
}
|
|
|
|
String timestamp = DateTime.now().format('yyyyMMdd_HHmmss');
|
|
String fileName = reportDevName + '_' + timestamp + getFileExtension();
|
|
|
|
return ShopifySyncDashboardController.createPublicFileLink(fileName, base64Content);
|
|
|
|
} catch (Exception e) {
|
|
System.debug('Error generating download link: ' + e.getMessage());
|
|
return URL.getOrgDomainUrl().toExternalForm() + '/' + reportId;
|
|
}
|
|
}
|
|
|
|
@TestVisible
|
|
private String getFileAsBase64(Reports.ReportResults rr) {
|
|
if (format == 'PDF') {
|
|
return getPdfAsBase64(rr);
|
|
} else if (format == 'EXCEL') {
|
|
return getExcelAsBase64(rr);
|
|
} else if (format == 'CSV') {
|
|
return getCsvAsBase64(rr);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ==================== PDF GENERATION ====================
|
|
|
|
@TestVisible
|
|
private String getPdfAsBase64(Reports.ReportResults rr) {
|
|
try {
|
|
List<String> headers = extractHeaders(rr);
|
|
List<List<String>> rows = extractRows(rr);
|
|
|
|
String title = reportDevName + ' Report';
|
|
return ShopifySyncDashboardController.generatePdfBase64(headers, rows, title);
|
|
|
|
} catch (Exception e) {
|
|
System.debug('PDF generation failed: ' + e.getMessage());
|
|
return getSummaryPdf();
|
|
}
|
|
}
|
|
|
|
@TestVisible
|
|
private String getSummaryPdf() {
|
|
try {
|
|
List<String> headers = new List<String>{'Report Name', 'Report ID', 'Format', 'Generated On'};
|
|
List<List<String>> rows = new List<List<String>>();
|
|
rows.add(new List<String>{reportDevName, reportId, format, DateTime.now().format()});
|
|
return ShopifySyncDashboardController.generatePdfBase64(headers, rows, reportDevName + ' Report Summary');
|
|
} catch (Exception e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==================== EXCEL GENERATION ====================
|
|
|
|
@TestVisible
|
|
private String getExcelAsBase64(Reports.ReportResults rr) {
|
|
try {
|
|
String excelXml = convertToExcel(rr);
|
|
if (String.isBlank(excelXml)) return null;
|
|
|
|
Blob excelBlob = Blob.valueOf(excelXml);
|
|
return EncodingUtil.base64Encode(excelBlob);
|
|
|
|
} catch (Exception e) {
|
|
System.debug('Excel generation failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==================== CSV GENERATION ====================
|
|
|
|
@TestVisible
|
|
private String getCsvAsBase64(Reports.ReportResults rr) {
|
|
try {
|
|
String csv = convertToCsv(rr);
|
|
if (String.isBlank(csv)) return null;
|
|
|
|
Blob csvBlob = Blob.valueOf(csv);
|
|
return EncodingUtil.base64Encode(csvBlob);
|
|
|
|
} catch (Exception e) {
|
|
System.debug('CSV generation failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==================== EMAIL ATTACHMENT ====================
|
|
|
|
@TestVisible
|
|
private Messaging.EmailFileAttachment buildReportAttachment(Reports.ReportResults rr) {
|
|
try {
|
|
String base64Content = getFileAsBase64(rr);
|
|
if (String.isBlank(base64Content)) return null;
|
|
|
|
Blob fileBlob = EncodingUtil.base64Decode(base64Content);
|
|
|
|
Messaging.EmailFileAttachment att = new Messaging.EmailFileAttachment();
|
|
String timestamp = DateTime.now().format('yyyyMMdd_HHmmss');
|
|
att.setFileName(reportDevName + '_' + timestamp + getFileExtension());
|
|
att.setBody(fileBlob);
|
|
|
|
if (format == 'PDF') {
|
|
att.setContentType('application/pdf');
|
|
} else if (format == 'EXCEL') {
|
|
att.setContentType('application/vnd.ms-excel');
|
|
} else if (format == 'CSV') {
|
|
att.setContentType('text/csv');
|
|
}
|
|
|
|
return att;
|
|
|
|
} catch (Exception e) {
|
|
System.debug('Attachment generation failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@TestVisible
|
|
private String getFileExtension() {
|
|
if (format == 'PDF') return '.pdf';
|
|
if (format == 'EXCEL') return '.xls';
|
|
if (format == 'CSV') return '.csv';
|
|
return '.txt';
|
|
}
|
|
|
|
// ==================== REPORT DATA EXTRACTION ====================
|
|
// FIXED: Uses only valid Salesforce API types
|
|
|
|
/**
|
|
* Extract column headers from ReportResults.
|
|
* Uses Reports.ReportMetadata.getDetailColumns() which returns List<String>
|
|
* (API names of columns), then looks up labels via ReportExtendedMetadata.
|
|
*/
|
|
@TestVisible
|
|
private List<String> extractHeaders(Reports.ReportResults rr) {
|
|
List<String> headers = new List<String>();
|
|
if (rr == null) return headers;
|
|
|
|
try {
|
|
// getDetailColumns() returns List<String> of API names
|
|
List<String> columnNames = rr.getReportMetadata().getDetailColumns();
|
|
|
|
// Get extended metadata for labels
|
|
Reports.ReportExtendedMetadata extMeta = rr.getReportExtendedMetadata();
|
|
Map<String, Reports.DetailColumn> detailCols = extMeta.getDetailColumnInfo();
|
|
|
|
for (String colName : columnNames) {
|
|
if (detailCols.containsKey(colName)) {
|
|
headers.add(detailCols.get(colName).getLabel());
|
|
} else {
|
|
headers.add(colName); // fallback to API name
|
|
}
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('Error extracting headers: ' + e.getMessage());
|
|
}
|
|
|
|
if (headers.isEmpty()) {
|
|
headers.add('Report Data');
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Extract row data from ReportResults using JSON serialization.
|
|
* This is the most reliable way to access nested ReportDataCell structures.
|
|
*/
|
|
@TestVisible
|
|
private List<List<String>> extractRows(Reports.ReportResults rr) {
|
|
List<List<String>> result = new List<List<String>>();
|
|
if (rr == null || rr.getFactMap() == null) return result;
|
|
|
|
try {
|
|
// Serialize the entire ReportResults to JSON and parse it
|
|
String jsonStr = JSON.serialize(rr);
|
|
Map<String, Object> reportData = (Map<String, Object>) JSON.deserializeUntyped(jsonStr);
|
|
return extractRowsFromReportData(reportData);
|
|
} catch (Exception e) {
|
|
System.debug('Error extracting rows: ' + e.getMessage());
|
|
System.debug('Stack trace: ' + e.getStackTraceString());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Walks an already-deserialized report JSON payload (factMap -> rows ->
|
|
* dataCells/cells -> label/value) and returns the extracted row values.
|
|
* Split out from extractRows() so this parsing logic can be unit-tested
|
|
* with hand-built maps — Reports.ReportResults has no public constructor
|
|
* and can't be mocked directly from Apex test code.
|
|
*/
|
|
@TestVisible
|
|
private List<List<String>> extractRowsFromReportData(Map<String, Object> reportData) {
|
|
List<List<String>> result = new List<List<String>>();
|
|
|
|
// Navigate to factMap
|
|
Object factMapObj = reportData != null ? reportData.get('factMap') : null;
|
|
if (factMapObj == null) {
|
|
System.debug('No factMap found in report data');
|
|
return result;
|
|
}
|
|
|
|
Map<String, Object> factMap = (Map<String, Object>) factMapObj;
|
|
System.debug('Processing ' + factMap.size() + ' fact map entries');
|
|
|
|
for (String key : factMap.keySet()) {
|
|
Object fact = factMap.get(key);
|
|
if (fact == null) continue;
|
|
|
|
String factJson = JSON.serialize(fact);
|
|
Map<String, Object> factData = (Map<String, Object>) JSON.deserializeUntyped(factJson);
|
|
|
|
// Try to find rows in the fact
|
|
Object rowsObj = factData.get('rows');
|
|
if (rowsObj == null) {
|
|
System.debug('Fact [' + key + '] has no rows');
|
|
continue;
|
|
}
|
|
|
|
List<Object> rows = (List<Object>) rowsObj;
|
|
System.debug('Fact [' + key + '] has ' + rows.size() + ' rows');
|
|
|
|
for (Object row : rows) {
|
|
if (row == null) continue;
|
|
|
|
String rowJson = JSON.serialize(row);
|
|
Map<String, Object> rowData = (Map<String, Object>) JSON.deserializeUntyped(rowJson);
|
|
|
|
// Each row has dataCells array
|
|
Object dataCellsObj = rowData.get('dataCells');
|
|
if (dataCellsObj == null) {
|
|
dataCellsObj = rowData.get('cells');
|
|
if (dataCellsObj == null) continue;
|
|
}
|
|
|
|
List<Object> dataCells = (List<Object>) dataCellsObj;
|
|
List<String> rowValues = new List<String>();
|
|
|
|
for (Object cell : dataCells) {
|
|
String cellJson = JSON.serialize(cell);
|
|
Map<String, Object> cellData = (Map<String, Object>) JSON.deserializeUntyped(cellJson);
|
|
|
|
// Prefer label (display value) over value (raw API value)
|
|
Object label = cellData.get('label');
|
|
if (label != null) {
|
|
rowValues.add(String.valueOf(label));
|
|
} else {
|
|
Object value = cellData.get('value');
|
|
rowValues.add(value != null ? String.valueOf(value) : '');
|
|
}
|
|
}
|
|
|
|
if (!rowValues.isEmpty()) {
|
|
result.add(rowValues);
|
|
}
|
|
}
|
|
}
|
|
|
|
System.debug('Total rows extracted: ' + result.size());
|
|
return result;
|
|
}
|
|
|
|
// ==================== FORMAT CONVERTERS ====================
|
|
|
|
@TestVisible
|
|
private String convertToExcel(Reports.ReportResults rr) {
|
|
List<String> columns = extractHeaders(rr);
|
|
List<List<String>> rows = extractRows(rr);
|
|
|
|
// Ensure we have at least some data
|
|
if (columns.isEmpty()) {
|
|
columns = new List<String>{'Report Name', 'Report ID', 'Format', 'Generated On'};
|
|
rows = new List<List<String>>();
|
|
rows.add(new List<String>{reportDevName, reportId, format, DateTime.now().format()});
|
|
}
|
|
|
|
String xml = '';
|
|
xml += '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
xml += '<?mso-application progid="Excel.Sheet"?>\n';
|
|
xml += '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"\n';
|
|
xml += ' xmlns:o="urn:schemas-microsoft-com:office:office"\n';
|
|
xml += ' xmlns:x="urn:schemas-microsoft-com:office:excel"\n';
|
|
xml += ' xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">\n';
|
|
xml += '<Worksheet ss:Name="Report">\n';
|
|
xml += '<Table>\n';
|
|
|
|
// Header row
|
|
xml += '<Row>\n';
|
|
for (String col : columns) {
|
|
xml += '<Cell><Data ss:Type="String">' + escapeXml(col) + '</Data></Cell>\n';
|
|
}
|
|
xml += '</Row>\n';
|
|
|
|
// Data rows
|
|
for (List<String> row : rows) {
|
|
xml += '<Row>\n';
|
|
for (String cell : row) {
|
|
xml += '<Cell><Data ss:Type="String">' + escapeXml(cell) + '</Data></Cell>\n';
|
|
}
|
|
xml += '</Row>\n';
|
|
}
|
|
|
|
xml += '</Table>\n';
|
|
xml += '</Worksheet>\n';
|
|
xml += '</Workbook>';
|
|
|
|
return xml;
|
|
}
|
|
|
|
@TestVisible
|
|
private String convertToCsv(Reports.ReportResults rr) {
|
|
List<String> columns = extractHeaders(rr);
|
|
List<List<String>> rows = extractRows(rr);
|
|
|
|
if (columns.isEmpty()) {
|
|
columns = new List<String>{'Report Name', 'Report ID', 'Format', 'Generated On'};
|
|
rows = new List<List<String>>();
|
|
rows.add(new List<String>{reportDevName, reportId, format, DateTime.now().format()});
|
|
}
|
|
|
|
String csv = '\uFEFF'; // BOM for UTF-8
|
|
csv += String.join(columns, ',') + '\n';
|
|
|
|
for (List<String> row : rows) {
|
|
List<String> rowStrings = new List<String>();
|
|
for (String cell : row) {
|
|
String value = cell != null ? cell : '';
|
|
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
|
|
value = '"' + value.replace('"', '""') + '"';
|
|
}
|
|
rowStrings.add(value);
|
|
}
|
|
csv += String.join(rowStrings, ',') + '\n';
|
|
}
|
|
|
|
return csv;
|
|
}
|
|
|
|
@TestVisible
|
|
private String escapeXml(String value) {
|
|
if (value == null) return '';
|
|
String escaped = value;
|
|
escaped = escaped.replace('&', '&');
|
|
escaped = escaped.replace('<', '<');
|
|
escaped = escaped.replace('>', '>');
|
|
escaped = escaped.replace('"', '"');
|
|
escaped = escaped.replace('\'', ''');
|
|
return escaped;
|
|
}
|
|
} |