Finally found some time to write this post. Well, not really… the time I mean. Last couple of months have been extremely busy and I really wish I could have written more. Anyway, this one was already on my list since Directions EMEA 2016. And with Dynamics 365 for FInancials lurking around the corner, this topic becomes even more relevant.
If you want to directly dive into the code, jump to GitHub and download it from there:
Do you know what this is?
It’s the option list you get when you print from the Dynamics NAV Web Client. All of these options generate a file that must be downloaded to the local computer before you can open it and print it. And this experience is the same with Dynamics 365 for Financials. Well, that pretty much describes the problem. Is this really user friendly? I don’t think so.
So let’s solve this problem! With a web service…
The web service
There are not so many web services that support remote printing. But I found one that does a perfect job. PrintNode proves to work fast and stable. Fast because the printjob is delivered in half a second to the printer of your choice. And it is stable, I have the client installed on my laptop since October 2016 and it still works without any problem. It has survived numerous updates, it was ignored for several months, but when I started the demo two weeks ago it still worked like a charm.
This is not a normal web service. Well, I mean, the web service itself is quite similar to previous web service examples that I have demonstrated here. But this web service has an extra component. Sending a print job to a web service doesn’t make any sense if that web service cannot connect to your local printers. To achieve this you to download an app that must be installed on the local computer. This client app is connected to the PrintNode server, accepts print jobs and forwards them to your printer.
So what do we need to get this whole circus up and running? Not so much actually. First step is to sign up for an account. Then you download the client and install it on your local computer. After the installation you enter the account credentials and from that moment on your printers are connected to the PrintNode server. Now you can start sending print jobs to the PrintNode web service which will forward them to the client app.
Before we jump to the code, a few remarks about the installation. You can create a single account and share that on multiple computers. All printers of these computers become available to that single account. Although the download page tells you that they support up to Windows 8, it perfectly works with Windows 10 and also on Windows Server 2012. The web service is not for free, but the costs are quite low actually.
PrintNode provides an API that makes it simple to integrate remote printing. With the API we can get a list of computers and printers that use our account. The example code uses this list to let the user select a printer and send the output of the report to the selected printer. Now this sounds easier than it really is.
I don’t want to go over the table and page objects that hold the setup and printers. Anyone who knows a little about Dynamics NAV should be able to understand these objects.
The really interesting objects are Codeunit 71000 PrintNode Management and Codeunit 71001 PrintNode Print Management.
Let’s first look at Codeunit 71000 PrintNode Management. In this Codeunit I have created three functions that use the PrintNode API. The functions RefreshComputers and RefreshPrinters are similar to the other web service examples. They use the generic function CallRESTWebService and the resulting JSON string is read into the table. If you have followed the other web service examples, this should look familiar to you.
The function NewPrintJob is a little bit different. The web service has a method printjob with which we can send a document to the printer. Currently only PDF files are supported. The web service supports two different ways of sending the PDF file. First option is to include the PDF content in the web service call. This is done by converting the PDF content into a Base64 encoded string. This is a way to send binary data over internet. The second option is to save the PDF file on a location that is available to the client app and only send a link to the file. For on-premise solutions, this could be a network location. For cloud solutions, I would recommend to store it on Azure. In my example I have chosen to include the PDF content into the web service.
Enough talking, let’s have a look at the code.
<br /> NewPrintJob(PrinterId : Integer;MemStream : DotNet "System.IO.MemoryStream") : Text<br /> bytes := MemStream.GetBuffer();<br /> Base64String := Convert.ToBase64String(bytes,0,bytes.Length);</p> <p>JTokenWriter := JTokenWriter.JTokenWriter;<br /> WITH JTokenWriter DO BEGIN<br /> WriteStartObject;<br /> WritePropertyName('printerId');<br /> WriteValue(FORMAT(PrinterId));<br /> WritePropertyName('title');<br /> WriteValue('Dynamics NAV PrintJob');<br /> WritePropertyName('contentType');<br /> WriteValue('pdf_base64');<br /> WritePropertyName('content');<br /> WriteValue(Base64String);<br /> WritePropertyName('source');<br /> WriteValue(FORMAT(USERID));<br /> WriteEndObject;<br /> JObject := Token;<br /> END;</p> <p>StringContent := StringContent.StringContent(JObject.ToString,Encoding.UTF8,'application/json');</p> <p>Parameters := Parameters.Dictionary();<br /> Parameters.Add('baseurl',APIBaseUrl);<br /> Parameters.Add('path','printjobs');<br /> Parameters.Add('restmethod','POST');<br /> Parameters.Add('username',GetAPIKey);<br /> Parameters.Add('httpcontent',StringContent);</p> <p>RESTWSManagement.CallRESTWebService(Parameters,HttpResponseMessage);</p> <p>result := HttpResponseMessage.Content.ReadAsStringAsync.Result;<br /> EXIT(result);<br />
The function accepts these parameters:
- PrinterId: the unique id of the selected printer
- MemStream: the PDF output of the report
The first two lines convert the data into a Base64 encoded string. Then we create the JSON request message, which holds the printer id and the Base64 converted PDF content.
Next, we create a StringContent that holds the JSON request message. The StringContent will be send to the printjobs method of the web service by using the POST method.
And that’s all the magic: Base64 converted PDF content that is passed in the HTTP request body together with the printer id that should printout the document.
The story doesn’t end here. We have one more hurdle to take. How to get the PDF output from the report? Function SendReportToPrintNode in Codeunit 71001 PrintNode Print Management has an example of how to do that.
<br /> LOCAL SendReportToPrintNode(ReportId : Integer;RecVariant : Variant) : Boolean<br /> IF NOT PrintNodeManagement.IsPrintNodeEnabled THEN<br /> EXIT;</p> <p>IF CURRENTCLIENTTYPE = CLIENTTYPE::Windows THEN<br /> EXIT;</p> <p>AllObjWithCaption.GET(AllObjWithCaption."Object Type"::Report,ReportId);</p> <p>IF RecVariant.ISRECORD THEN BEGIN<br /> RecRef.GETTABLE(RecVariant);<br /> XmlParameters := GetXmlParameters(RecRef);<br /> Filename := STRSUBSTNO('%1 - %2 - %3.pdf',COMPANYNAME,AllObjWithCaption."Object Caption",RecRef.RECORDID)<br /> END ELSE BEGIN<br /> Filename := STRSUBSTNO('%1 - %2 - %3.pdf',COMPANYNAME,AllObjWithCaption."Object Caption",CURRENTDATETIME);<br /> END;</p> <p>XmlParameters := REPORT.RUNREQUESTPAGE(ReportId,XmlParameters);<br /> IF XmlParameters = '' THEN<br /> ERROR('');</p> <p>PrintNodeManagement.Refresh;<br /> COMMIT;<br /> PrintNodePrinter.SETRANGE("Computer State",'connected');<br /> PrintNodePrinter.SETRANGE(State,'online');<br /> IF PAGE.RUNMODAL(0,PrintNodePrinter) &lt;&gt; ACTION::LookupOK THEN<br /> ERROR('');</p> <p>TempBlob.Blob.CREATEOUTSTREAM(OutStr);</p> <p>IF RecVariant.ISRECORD THEN<br /> REPORT.SAVEAS(ReportId,XmlParameters,REPORTFORMAT::Pdf,OutStr,RecRef)<br /> ELSE<br /> REPORT.SAVEAS(ReportId,XmlParameters,REPORTFORMAT::Pdf,OutStr);</p> <p>TempBlob.Blob.CREATEINSTREAM(InStr);<br /> MemStream := MemStream.MemoryStream();<br /> COPYSTREAM(MemStream,InStr);</p> <p>result := PrintNodeManagement.NewPrintJob(PrintNodePrinter.Id,MemStream,Filename);</p> <p>MESSAGE(PrintJobSendMsg,result);<br /> EXIT(TRUE);<br />
This function is a generic function that should be able to handle output for any report. It accepts these parameters:
- ReportId: the id of the report that we want the output from
- RecVariant: optional, if a record is passed into this parameter then that will be used to print the report, including its filters
First line checks whether you are running the web client and exits if that is not the case. This is optional, it should actually work from the windows client as well.
Then we check if the parameter RecVariant is holding a record type. If that is the case, the function GetXmlParameters is used to create a string that holds the current filter on that record.
<br /> LOCAL GetXmlParameters(RecRef : RecordRef) : Text<br /> XmlDoc := XmlDoc.XmlDocument;</p> <p>XMLDOMMgt.AddRootElement(XmlDoc,'ReportParameters',ReportParametersXmlNode);<br /> XMLDOMMgt.AddDeclaration(XmlDoc,'1.0','utf-8','yes');</p> <p>XMLDOMMgt.AddElement(ReportParametersXmlNode,'DataItems','','',DataItemsXmlNode);<br /> XMLDOMMgt.AddElement(DataItemsXmlNode,'DataItem',RecRef.GETVIEW(FALSE),'',DataItemXmlNode);<br /> XMLDOMMgt.AddAttribute(DataItemXmlNode,'name',RecRef.CAPTION);</p> <p>EXIT(XmlDoc.InnerXml);<br />
The XML parameter string is then passed into the RUNREQUESTPAGE function. This function is used to store the request settings into an XML document. It doesn’t print the report, it just presents the request page to the user and returns the settings. We use this approach because REPORT.RUN does not return the output so we can grab it and send it to the PrintNode server. Also REPORT.SAVEASPDF is not a good option because it doesn’t show the request page.
But the command REPORT.SAVEAS gives us all what we need. We can pass the previously saved parameters from the request page and grab the output in a OutStream variable. This is exactly what we need! The OutStream is copied to a .Net MemoryStream which is passed into the function NewPrintJob.
Between displaying the request page and the actual execution of the report, the user gets a lookup window to select the printer. The printer lists refreshes first to get the actual printer list that is currently online and can be used.
Then a word about attaching this to the print actions. In Codeunit 71001 I have added an example how to subscribe to the OnBeforeAction of the Sales Order Confirmation. Unfortunately, this only works if the action contains code in the OnAction trigger. If the action isn’t using code, but instead uses the RunObject property, then there is no OnBeforeAction trigger event. Which is theoratically correct, but the result is that a lot of actions cannot be handled with OnBeforeAction. The only option is then to replace the action itself.
<br /> LOCAL [EventSubscriber] SalesOrderCard_OnBeforePrintSalesOrderConfirmation(VAR Rec : Record "Sales Header")<br /> HandlePrintSalesOrderConfirmation(Rec);</p> <p>LOCAL [EventSubscriber] SalesOrderList_OnBeforePrintSalesOrderConfirmation(VAR Rec : Record "Sales Header")<br /> HandlePrintSalesOrderConfirmation(Rec);</p> <p>LOCAL HandlePrintSalesOrderConfirmation(SalesHeader : Record "Sales Header")<br /> SalesHeader.SETRECFILTER;<br /> IF SendReportToPrintNode(REPORT::"Order Confirmation",SalesHeader) THEN BEGIN<br /> COMMIT;<br /> ERROR('');<br /> END;</p> <p>LOCAL [EventSubscriber] PostedSalesInvoiceCard_OnBeforePrintSalesInvoice(VAR Rec : Record "Sales Invoice Header")<br /> HandlePrintSalesInvoice(Rec);</p> <p>LOCAL [EventSubscriber] PostedSalesInvoiceList_OnBeforePrintSalesInvoice(VAR Rec : Record "Sales Invoice Header")<br /> HandlePrintSalesInvoice(Rec);</p> <p>LOCAL HandlePrintSalesInvoice(SalesInvoiceHeader : Record "Sales Invoice Header")<br /> SalesInvoiceHeader.SETRECFILTER;<br /> IF SendReportToPrintNode(REPORT::"Standard Sales - Invoice",SalesInvoiceHeader) THEN BEGIN<br /> COMMIT;<br /> ERROR('');<br /> END;<br />
In the function HandlePrintSalesOrderConfirmation and HandlePrintSalesInvoice you see a COMMIT and ERROR(‘’). The reason for this is that you cannot override the existing action code. If you don’t use ERROR(‘’) the report will be printed twice. That’s not what we want. The COMMIT is there because the report might have written data to the database and we want to save it before we error out. And yes, of course you need to use report selections here, but I leave that to you, I trust you can do that.
Well, that’s it! A long blogpost, and still not ready. I feel uncomfortable with the way how we need to catch the report print and cancel further processing. I will come back to that in a next blog post.
For now, enjoy this piece of code!