背景

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

报表导出页面

题外话,允许导出 Excel 格式可能导致用户线下修改数据,再重新生成 PDF。虽然 PDF 也可以被编辑,但这种方案至少能起到"防君子不防小人"的约束作用。

解决方案对比

方案一:前端生成 PDF

  • 实现方式:使用第三方 JS 库(如 PrintJS)重新设计报表
  • 优点:完全自定义,控制灵活
  • 缺点:需要重新开发,成本较高

方案二:

  • 实现方式:通过 JavaScript 调用现有 SSRS 报表,强制导出 PDF 格式
  • 优点:复用现有报表,开发效率高
  • 缺点:依赖现有报表结构

我的选择:考虑到现有报表已投入大量开发资源,采用方案二,通过自定义按钮实现强制 PDF 导出。

实现效果

点击自定义导出按钮:

点击 Export Account Info

下载 PDF 文件,打开预览:

打开下载的 PDF文件

实现步骤

项目假设

我们已在客户实体上部署了名为 PrintAccount.rdl 的自定义 SSRS 报表:

客户表单上添加了一张自定义的SSRS报表

步骤一:添加 JavaScript 脚本

  • 在 Web 资源中创建 Account.js 文件
  • 将附录中的完整代码复制到文件中
  • 重要:保存后务必发布更改

添加Javascript脚本

步骤二:配置自定义按钮

这里我使用新版的按钮编辑界面。当然,你也可以使用 Ribbon Workbench 添加按钮,达到目的就行。

登录 Power Apps:应用 → 选择应用 → 编辑 选择编辑命令栏-01

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

选择编辑命令栏-02

选择 Main Form

选择 Main Form

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

绑定 Account.js

最后保存并发布

技术实现详解

为什么不使用 Convert to PDF 功能?

为什么不使用系统的标准功能来实现生成PDF

标准"Convert to PDF"功能存在以下限制:

  • 关联数据过滤问题:无法过滤停用的关联记录
  • 示例说明:客户与联系人是一对多关系,使用 Word 模板转 PDF 时,即使联系人已停用,仍会显示在输出中

关键技术点

代码中的 CRM_FilteredAccount 在哪里找?

代码中的 CRM_FilteredAccount 是 SSRS 报表的参数,用于传递 FetchXML 查询条件:

CRM_FilteredAccount

核心方法说明

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;
    },
  };
})();

如果本文对你有所帮助,可以请我喝杯咖啡

(完)