背景
在 Dynamics 365 项目中,我们为报价单实体开发了一套复杂的 SSRS 报表,用作销售团队向客户提供的标准报价模板。然而,标准的报表导出功能存在一个管理隐患:用户可以选择多种导出格式(Excel、Word、PDF 等),这不符合客户的管理要求。甲方明确要求销售团队只能导出 PDF 格式。

题外话,允许导出 Excel 格式可能导致用户线下修改数据,再重新生成 PDF。虽然 PDF 也可以被编辑,但这种方案至少能起到"防君子不防小人"的约束作用。
解决方案对比
方案一:前端生成 PDF
- 实现方式:使用第三方 JS 库(如 PrintJS)重新设计报表
- 优点:完全自定义,控制灵活
- 缺点:需要重新开发,成本较高
方案二:
- 实现方式:通过 JavaScript 调用现有 SSRS 报表,强制导出 PDF 格式
- 优点:复用现有报表,开发效率高
- 缺点:依赖现有报表结构
我的选择:考虑到现有报表已投入大量开发资源,采用方案二,通过自定义按钮实现强制 PDF 导出。
实现效果
点击自定义导出按钮:

下载 PDF 文件,打开预览:

实现步骤
项目假设
我们已在客户实体上部署了名为 PrintAccount.rdl 的自定义 SSRS 报表:

步骤一:添加 JavaScript 脚本
- 在 Web 资源中创建 Account.js 文件
- 将附录中的完整代码复制到文件中
- 重要:保存后务必发布更改

步骤二:配置自定义按钮
这里我使用新版的按钮编辑界面。当然,你也可以使用 Ribbon Workbench 添加按钮,达到目的就行。
登录 Power Apps:应用 → 选择应用 → 编辑

选择客户视图 → 编辑命令栏

选择 Main Form

绑定 Account.js,并填写相关属性

最后保存并发布
技术实现详解
为什么不使用 Convert to PDF 功能?

标准"Convert to PDF"功能存在以下限制:
- 关联数据过滤问题:无法过滤停用的关联记录
- 示例说明:客户与联系人是一对多关系,使用 Word 模板转 PDF 时,即使联系人已停用,仍会显示在输出中
关键技术点
代码中的 CRM_FilteredAccount 在哪里找?
代码中的 CRM_FilteredAccount 是 SSRS 报表的参数,用于传递 FetchXML 查询条件:

核心方法说明
ExportPrintAccountReportPDF(); // 导出PDF主方法
GetReportIdByReportFileName(); // 获取报表ID
ExecuteReport(); // 执行报表生成
Get_SSRS_Report_PDFBase64(); // 获取PDF Base64数据
附录
Account/Account.js
/**
* Account Entity Javascript.
*/
if (Gdh === undefined) {
var Gdh = {};
}
if (Gdh.D365 === undefined) {
Gdh.D365 = {};
}
Gdh.D365.Account = (function () {
"use strict";
return {
Constants: {
Fields: {
AccountName: "name",
Phone: "telephone1",
Fax: "fax",
Website: "websiteurl",
},
Reports: {
PrintAccountReport: "PrintAccount.rdl",
},
SystemAdminId: "SystemAdminId",
},
OnLoad: function (ExecutionContext) {
try {
let objFormContext = ExecutionContext.getFormContext();
} catch (e) {
console.error("Error during OnLoad: ", e);
}
},
ExportPrintAccountReportPDF: function (primaryControl) {
let objFormContext = primaryControl;
let CurrentAccountId = objFormContext.data.entity
.getId()
.replace("{", "")
.replace("}", "");
let that = this;
console.log(CurrentAccountId, CurrentAccountId);
let selectAttributes = `${that.Constants.Fields.AccountName}`;
console.log(selectAttributes, selectAttributes);
let accountEn = this.RetrieveSingleRecord(
"accounts",
CurrentAccountId,
selectAttributes
);
let accountName = accountEn[this.Constants.Fields.AccountName];
// CRM_FilteredAccount -> SSRS report argument
let reportPrefilter =
"CRM_FilteredAccount=" +
"<fetch version='1.0' output-format='xml-platform' mapping='logical' distinct='false'>" +
"<entity name='account'>" +
" <all-attributes />" +
" <filter type='and'>" +
" <condition attribute='accountid' operator='eq' value='" +
CurrentAccountId +
"' />" +
" </filter>" +
"</entity>" +
"</fetch>";
let arrReportSession = this.ExecuteReport(
this.Constants.Reports.PrintAccountReport,
reportPrefilter
);
this.Get_SSRS_Report_PDFBase64(arrReportSession, 2052)
.then(function (base64String) {
// Size of the file in KB
let fSize =
encodeURIComponent(base64String).replace(/%../g, "x").length / 1024;
let openFileOptions = { openMode: 2 };
let file = {};
file.fileContent = base64String;
file.fileSize = fSize;
// Set file name
file.fileName = accountName + " - Info" + ".pdf";
file.mimeType = "application/pdf";
Xrm.Navigation.openFile(file, openFileOptions);
})
.catch(function (error) {
console.error(error);
});
},
GetReportIdByReportFileName: function (reportFileName) {
let lValue = "";
let lResponse = this.RetrieveMultipleRecord(
"reports",
"filename eq '" + reportFileName + "'",
"reportid",
false
);
if (
lResponse !== null &&
lResponse !== undefined &&
lResponse.value.length > 0
) {
lValue = lResponse.value[0]["reportid"];
}
return lValue;
},
ExecuteReport: function (reportFileName, reportPrefilter) {
let reportGuid = this.GetReportIdByReportFileName(reportFileName);
let pth = this.GetClientUrl() + "/CRMReports/rsviewer/ReportViewer.aspx";
let orgUniqueName = Xrm.Utility.getGlobalContext().getOrgUniqueName();
let query =
"id=%7B" +
reportGuid +
"%7D&uniquename=" +
orgUniqueName +
"&iscustomreport=true&reportnameonsrs=&reportName=" +
reportFileName +
"&isScheduledReport=false&p:" +
reportPrefilter;
let retrieveEntityReq = new XMLHttpRequest();
retrieveEntityReq.open("POST", pth, false);
retrieveEntityReq.setRequestHeader("Accept", "*/*");
retrieveEntityReq.setRequestHeader(
"Content-Type",
"application/x-www-form-urlencoded"
);
retrieveEntityReq.send(query);
let x = retrieveEntityReq.responseText.lastIndexOf("ReportSession=");
let y = retrieveEntityReq.responseText.lastIndexOf("ControlID=");
let ret = [];
ret[0] = retrieveEntityReq.responseText.slice(x + 14, x + 14 + 24);
ret[1] = retrieveEntityReq.responseText.slice(y + 10, y + 10 + 32);
return ret;
},
/**
*
* @param {any} arrResponseSession
* @param {any} lcId (Language code)
* @returns
*/
Get_SSRS_Report_PDFBase64: function (arrResponseSession, lcId) {
let that = this;
return new Promise(function (resolve, reject) {
let pth =
that.GetClientUrl() +
"/Reserved.ReportViewerWebControl.axd?ReportSession=" +
arrResponseSession[0] +
"&Culture=" +
lcId +
"&CultureOverrides=True&UICulture=" +
lcId +
"&UICultureOverrides=True&ReportStack=1&ControlID=" +
arrResponseSession[1] +
"&OpType=Export&FileName=Public&ContentDisposition=OnlyHtmlInline&Format=PDF";
let retrieveEntityReq = new XMLHttpRequest();
retrieveEntityReq.open("GET", pth, true);
retrieveEntityReq.setRequestHeader("Accept", "*/*");
retrieveEntityReq.responseType = "arraybuffer";
retrieveEntityReq.onreadystatechange = function () {
if (
retrieveEntityReq.readyState == 4 &&
retrieveEntityReq.status == 200
) {
let binary = "";
let bytes = new Uint8Array(this.response);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
let base64PDFString = btoa(binary);
resolve(base64PDFString);
}
};
retrieveEntityReq.send();
});
},
GetClientUrl: function () {
let lGlobalContext = "";
try {
lGlobalContext = Xrm.Utility.getGlobalContext();
} catch (e) {
lGlobalContext = parent.Xrm.Utility.getGlobalContext();
}
if (lGlobalContext !== null) {
return lGlobalContext.getClientUrl();
}
return null;
},
RetrieveMultipleRecord: function (
lEntityName,
lFilter,
lCommaSeparatedAttributeNames,
isAdmin
) {
let lResponse = null;
let lXMLHttpRequest = new XMLHttpRequest();
lXMLHttpRequest.open(
"GET",
this.GetClientUrl() +
"/api/data/v9.2/" +
lEntityName +
"?$select=" +
lCommaSeparatedAttributeNames +
"&$filter=" +
lFilter,
false
);
lXMLHttpRequest.setRequestHeader("OData-MaxVersion", "4.0");
lXMLHttpRequest.setRequestHeader("OData-Version", "4.0");
lXMLHttpRequest.setRequestHeader("Accept", "application/json");
lXMLHttpRequest.setRequestHeader(
"Content-Type",
"application/json; charset=utf-8"
);
lXMLHttpRequest.setRequestHeader(
"Prefer",
'odata.include-annotations="*"'
);
// If IsAdmin is true, it is executed as an administrator
if (isAdmin) {
lXMLHttpRequest.setRequestHeader(
"MSCRMCallerID",
this.GetConfigurationValue(this.Constants.SystemAdminId)
);
}
lXMLHttpRequest.onreadystatechange = function () {
if (this.readyState === 4) {
lXMLHttpRequest.onreadystatechange = null;
if (this.status === 200) {
lResponse = JSON.parse(this.response);
} else {
Xrm.Navigation.openAlertDialog(
"An exception has occurred, please contact the system administrator."
);
console.log("Error:");
console.log(this.statusText);
}
}
};
lXMLHttpRequest.send();
return lResponse;
},
RetrieveSingleRecord: function (
lEntityName,
lEntityId,
lCommaSeparatedAttributeNames,
admin
) {
let lResponse = null;
let lXMLHttpRequest = new XMLHttpRequest();
lXMLHttpRequest.open(
"GET",
this.GetClientUrl() +
"/api/data/v9.2/" +
lEntityName +
"(" +
lEntityId +
")" +
"?$select=" +
lCommaSeparatedAttributeNames,
false
);
lXMLHttpRequest.setRequestHeader("OData-MaxVersion", "4.0");
lXMLHttpRequest.setRequestHeader("OData-Version", "4.0");
lXMLHttpRequest.setRequestHeader("Accept", "application/json");
lXMLHttpRequest.setRequestHeader(
"Content-Type",
"application/json; charset=utf-8"
);
lXMLHttpRequest.setRequestHeader(
"Prefer",
'odata.include-annotations="*"'
);
if (admin) {
lXMLHttpRequest.setRequestHeader(
"MSCRMCallerID",
this.GetConfigurationValue(this.Constants.SystemAdminId)
);
}
lXMLHttpRequest.onreadystatechange = function () {
if (this.readyState === 4) {
lXMLHttpRequest.onreadystatechange = null;
if (this.status === 200) {
lResponse = JSON.parse(this.response);
} else {
Xrm.Navigation.openAlertDialog(
"An exception has occurred, please contact the system administrator."
);
console.log("Error:");
console.log(this.statusText);
}
}
};
lXMLHttpRequest.send();
return lResponse;
},
/**
* Get Configuration Value
* P.S: This is my own new configuration entity, there are two main fields: (1) name , (2) value.
* if you need to use, please create and modify the following field information
* @param {any} configName
* @returns
*/
GetConfigurationValue: function (configName) {
let lValue = "";
let lResponse = this.RetrieveMultipleRecord(
"Your Config Entity Logical Collection Name",
"gdh_name eq '" + configName + "'",
"gdh_value"
);
if (
lResponse !== null &&
lResponse !== undefined &&
lResponse.value.length > 0
) {
lValue = lResponse.value[0][this.Constants.ConfigurationsField.Value];
}
return lValue;
},
};
})();
如果本文对你有所帮助,可以请我喝杯咖啡
(完)