58c4995cfb7f9cfd2b7b8ec3080f3c9670240d05
[xtuple] / node-datasource / routes / generate_report.js
1 /*jshint node:true, indent:2, curly:false, eqeqeq:true, immed:true, latedef:true, newcap:true, noarg:true,
2 regexp:true, undef:true, strict:true, trailing:true, white:true */
3 /*global X:true, XT:true, XM:true, SYS:true */
4
5 (function () {
6   "use strict";
7
8   //
9   // DEPENDENCIES
10   //
11
12   var _ = require("underscore"),
13     async = require("async"),
14     fs = require("fs"),
15     path = require("path"),
16     ipp = require("ipp"),
17     Report = require('fluentreports').Report,
18     queryForData = require("./export").queryForData;
19
20   /**
21     Generates a report using fluentReports
22
23     @property req.query.nameSpace
24     @property req.query.type
25     @property req.query.id
26     @property req.query.action
27
28     Sample URL:
29     https://localhost:8443/dev/generate-report?nameSpace=XM&type=Invoice&id=60000&action=print
30
31    */
32   var generateReport = function (req, res) {
33
34     //
35     // VARIABLES THAT SPAN MULTIPLE STEPS
36     //
37     var reportDefinition,
38       rawData = {}, // raw data object
39       reportData, // array with report data
40       username = req.session.passport.user.id,
41       databaseName = req.session.passport.user.organization,
42       // TODO: introduce pseudorandomness (maybe a timestamp) to avoid collisions
43       reportName = req.query.type.toLowerCase() + req.query.id + ".pdf",
44       auxilliaryInfo = req.query.auxilliaryInfo,
45       workingDir = path.join(__dirname, "../temp", databaseName),
46       reportPath = path.join(workingDir, reportName),
47       imageFilenameMap = {},
48       translations;
49
50     //
51     // HELPER FUNCTIONS FOR DATA TRANSFORMATION
52     //
53
54     /**
55       We receive the data in the form we're familiar with: an object that represents the head,
56       which has an array that represents item data (such as InvoiceLines).
57
58       Fluent expects the data to be just an array, which is the array of the line items with
59       head info copied redundantly.
60
61       As a convention we'll put a * as prefix in front of the keys of the item data.
62
63       This function performs both these transformtations on the data object.
64     */
65     var transformDataStructure = function (data) {
66       // TODO: detailAttribute could be inferred by looking at whatever comes before the *
67       // in the detailElements definition.
68
69       if (!reportDefinition.settings.detailAttribute) {
70         // no children, so no transformation is necessary
71         return [data];
72       }
73
74       return _.map(data[reportDefinition.settings.detailAttribute], function (detail) {
75         var pathedDetail = {};
76         _.each(detail, function (detailValue, detailKey) {
77           pathedDetail[reportDefinition.settings.detailAttribute + "*" + detailKey] = detailValue;
78         });
79         return _.extend({}, data, pathedDetail);
80       });
81     };
82
83     /**
84       Helper function to translate strings
85      */
86     var loc = function (s) {
87       return translations[s] || s;
88     };
89
90     /**
91       Resolve the xTuple JSON convention for report element definition to the
92       output expected from fluentReports by swapping in the data fields.
93
94       FluentReports wants its definition key to be a string in some cases (see the
95       textOnly parameter), and in other cases in the "data" attribute of an object.
96
97       The xTuple standard is to use "text" or "attr" instead of data. Text means text,
98       attr refers to the attribute name on the data object. This function accepts either
99       and crams the appropriate value into "data" for fluent (or just returns the string).
100      */
101     var marryData = function (detailDef, data, textOnly) {
102
103       return _.map(detailDef, function (def) {
104         var text = def.attr ? XT.String.traverseDots(data, def.attr) : loc(def.text);
105         if (def.text && def.label === true) {
106           // label=true on text just means add a colon
107           text = text + ": ";
108         } else if (def.label === true) {
109           // label=true on an attr means add the attr name as a label
110           text = loc("_" + def.attr) + ": " + text;
111         } else if (def.label) {
112           // custom label
113           text = loc(def.label) + ": " + text;
114         }
115         if (textOnly) {
116           return text;
117         }
118
119         // TODO: maybe support any attributes? Right now we ignore all but these three
120         var obj = {
121           data: text,
122           width: def.width,
123           align: def.align || 2 // default to "center"
124         };
125
126         return obj;
127       });
128     };
129
130     /**
131       Custom transformations depending on the element descriptions.
132
133       TODO: support more custom transforms like def.transform === 'address' which would need
134       to do stuff like smash city state zip into one line. The function to do this can't live
135       in the json definition, but we can support a set of custom transformations here
136       that can be referred to in the json definition.
137      */
138     var transformElementData = function (def, data) {
139       var textOnly,
140         mapSource,
141         params;
142
143       if (def.transform) {
144         params = marryData(def.definition, data, true);
145         return transformFunctions[def.transform].apply(this, params);
146       }
147
148       if (def.element === 'image') {
149         // if the image is not found, we don't want to print it
150         mapSource = _.isString(def.definition) ? def.definition : (def.definition[0].attr || def.definition[0].text);
151         if (!imageFilenameMap[mapSource]) {
152           return "";
153         }
154
155         // we save the images under a different name than they're described in the definition
156         return path.join(workingDir, imageFilenameMap[mapSource]);
157       }
158
159       // these elements are expecting a parameter that is a number, not
160       if (def.element === 'bandLine' || def.element === 'fontSize' ||
161         def.element === 'margins') {
162         return def.size;
163       }
164
165       // "print" elements (aka the default) only want strings as the definition
166       textOnly = def.element === "print" || !def.element;
167
168       return marryData(def.definition, data, textOnly);
169     };
170
171     var formatAddress = function (name, address1, address2, address3, city, state, code, country) {
172       var address = [];
173       if (name) { address.push(name); }
174       if (address1) {address.push(address1); }
175       if (address2) {address.push(address2); }
176       if (address3) {address.push(address3); }
177       if (city || state || code) {
178         var cityStateZip = (city || '') +
179               (city && (state || code) ? ' '  : '') +
180               (state || '') +
181               (state && code ? ' '  : '') +
182               (code || '');
183         address.push(cityStateZip);
184       }
185       if (country) { address.push(country); }
186       return address;
187     };
188
189     // this is very similar to a function on the XM.Location model
190     var formatArbl = function (aisle, rack, bin, location) {
191       return [_.filter(arguments, function (item) {
192         return !_.isEmpty(item);
193       }).join("-")];
194     };
195
196     var formatFullName = function (firstName, lastName, honorific, suffix) {
197       var fullName = [];
198       if (honorific) { fullName.push(honorific +  ' '); }
199       fullName.push(firstName + ' ' + lastName);
200       if (suffix) { fullName.push(' ' + suffix); }
201       return fullName;
202     };
203
204     var transformFunctions = {
205       fullname: formatFullName,
206       address: formatAddress,
207       arbl: formatArbl
208     };
209
210     /**
211       The "element" (default to "print") is the method on the report
212       object that we are going to call to draw the pdf. That's the magic
213       that lets us represent the fluentReport functions as json objects.
214     */
215     var printDefinition = function (report, data, definition) {
216       _.each(definition, function (def) {
217         var elementData = transformElementData(def, data);
218         if (elementData) {
219           // debug
220           // console.log(elementData);
221           report[def.element || "print"](elementData, def.options);
222         }
223       });
224     };
225
226     //
227     // WAYS TO RETURN TO THE USER
228     //
229
230     /**
231       Stream the pdf to the browser (on a separate tab, presumably)
232      */
233     var responseDisplay = function (res, data, done) {
234       res.header("Content-Type", "application/pdf");
235       res.send(data);
236       done();
237     };
238
239     /**
240       Stream the pdf to the user as a download
241      */
242     var responseDownload = function (res, data, done) {
243       res.header("Content-Type", "application/pdf");
244       res.attachment(reportPath);
245       res.send(data);
246       done();
247     };
248
249     /**
250       Send an email
251      */
252     var responseEmail = function (res, data, done) {
253
254       var emailProfile;
255       var fetchEmailProfile = function (done) {
256         var emailProfileId = reportData[0].customer.emailProfile,
257           statusChanged = function (model, status, options) {
258             if (status === XM.Model.READY_CLEAN) {
259               emailProfile.off("statusChange", statusChanged);
260               done();
261             }
262           };
263         if (!emailProfileId) {
264           done({isError: true, message: "Error: no email profile associated with customer"});
265           return;
266         }
267         emailProfile = new SYS.CustomerEmailProfile();
268         emailProfile.on("statusChange", statusChanged);
269         emailProfile.fetch({
270           id: emailProfileId,
271           database: databaseName,
272           username: username
273         });
274       };
275
276       var sendEmail = function (done) {
277         var formattedContent = {},
278           callback = function (error, response) {
279             if (error) {
280               X.log("Email error", error);
281               res.send({isError: true, message: "Error emailing"});
282               done();
283             } else {
284               res.send({message: "Email success"});
285               done();
286             }
287           };
288
289         // populate the template
290         _.each(emailProfile.attributes, function (value, key, list) {
291           if (typeof value === 'string') {
292             formattedContent[key] = XT.String.formatBraces(reportData[0], value);
293           }
294         });
295
296         formattedContent.text = formattedContent.body;
297         formattedContent.attachments = [{fileName: reportPath, contents: data, contentType: "application/pdf"}];
298
299         X.smtpTransport.sendMail(formattedContent, callback);
300       };
301
302       async.series([
303         fetchEmailProfile,
304         sendEmail
305       ], done);
306     };
307
308     /**
309       Silent-print to a printer registered in the node-datasource.
310      */
311     var responsePrint = function (res, data, done) {
312       var printer = ipp.Printer(X.options.datasource.printer),
313         msg = {
314           "operation-attributes-tag": {
315             "job-name": "Silent Print",
316             "document-format": "application/pdf"
317           },
318           data: data
319         };
320
321       printer.execute("Print-Job", msg, function (error, result) {
322         if (error) {
323           X.log("Print error", error);
324           res.send({isError: true, message: "Error printing"});
325           done();
326         } else {
327           res.send({message: "Print Success"});
328           done();
329         }
330       });
331     };
332
333     // Convenience hash to avoid if-else
334     var responseFunctions = {
335       display: responseDisplay,
336       download: responseDownload,
337       email: responseEmail,
338       print: responsePrint
339     };
340
341
342     //
343     // STEPS TO PERFORM ROUTE
344     //
345
346     /**
347       Make a directory node-datasource/temp if none exists
348      */
349     var createTempDir = function (done) {
350       fs.exists("./temp", function (exists) {
351         if (exists) {
352           done();
353         } else {
354           fs.mkdir("./temp", done);
355         }
356       });
357     };
358
359     /**
360       Make a directory node-datasource/temp/orgname if none exists
361      */
362     var createTempOrgDir = function (done) {
363       fs.exists("./temp/" + databaseName, function (exists) {
364         if (exists) {
365           done();
366         } else {
367           fs.mkdir("./temp/" + databaseName, done);
368         }
369       });
370     };
371
372     /**
373       Fetch the highest-grade report definition for this business object.
374      */
375     var fetchReportDefinition = function (done) {
376       var reportDefinitionColl = new SYS.ReportDefinitionCollection(),
377         afterFetch = function () {
378           if (reportDefinitionColl.getStatus() === XM.Model.READY_CLEAN) {
379             reportDefinitionColl.off("statusChange", afterFetch);
380             if (reportDefinitionColl.models[0]) {
381               reportDefinition = JSON.parse(reportDefinitionColl.models[0].get("definition"));
382             } else {
383               done({description: "Report Definition not found."});
384               return;
385             }
386             done();
387           }
388         };
389
390       reportDefinitionColl.on("statusChange", afterFetch);
391       reportDefinitionColl.fetch({
392         query: {
393           parameters: [{
394             attribute: "recordType",
395             value: req.query.nameSpace + "." + req.query.type
396           }],
397           rowLimit: 1,
398           orderBy: [{
399             attribute: "grade",
400             descending: true
401           }]
402         },
403         database: databaseName,
404         username: username
405       });
406     };
407
408     //
409     // Helper function for fetchImages
410     //
411     var queryDatabaseForImages = function (imageNames, done) {
412       var fileCollection = new SYS.FileCollection(),
413         afterFetch = function () {
414           if (fileCollection.getStatus() === XM.Model.READY_CLEAN) {
415             fileCollection.off("statusChange", afterFetch);
416             done(null, fileCollection);
417           }
418         };
419
420       fileCollection.on("statusChange", afterFetch);
421       fileCollection.fetch({
422         query: {
423           parameters: [{
424             attribute: "name",
425             operator: "ANY",
426             value: imageNames
427           }]
428         },
429         database: databaseName,
430         username: username
431       });
432     };
433
434     //
435     // Helper function for writing image
436     //
437     var writeImageToFilesystem = function (fileModel, done) {
438       // XXX this might be an expensive synchronous operation
439       var buffer = new Buffer(fileModel.get("data"));
440
441       imageFilenameMap[fileModel.get("name")] = fileModel.get("description");
442       fs.writeFile(path.join(workingDir, fileModel.get("description")), buffer, done);
443     };
444
445     /**
446       We support an image element in the json definition. The definition of that element
447       is a string that is the name of an XM.File. We fetch these files and put them in the
448       temp directory for future use.
449      */
450     var fetchImages = function (done) {
451       //
452       // Figure out what images we need to fetch, if any
453       //
454       var allElements = _.flatten(_.union(reportDefinition.headerElements,
455           reportDefinition.detailElements, reportDefinition.footerElements)),
456         allImages = _.unique(_.pluck(_.filter(allElements, function (el) {
457           return el.element === "image" && el.imageType !== "qr";
458         }), "definition"));
459         // thanks Jeremy
460
461       if (allImages.length === 0) {
462         // no need to try to fetch no images
463         done();
464         return;
465       }
466
467       //
468       // TODO: use the working dir as a cache
469       //
470
471       //
472       // Get the images
473       //
474       queryDatabaseForImages(allImages, function (err, fileCollection) {
475         //
476         // Write the images to the filesystem
477         //
478
479         async.map(fileCollection.models, writeImageToFilesystem, done);
480       });
481     };
482
483     /**
484       Fetch the remit to name and address information, but only if it
485       is needed.
486     */
487     var fetchRemitTo = function (done) {
488       var allElements = _.flatten(reportDefinition.headerElements),
489         definitions = _.flatten(_.compact(_.pluck(allElements, "definition"))),
490         remitToFields = _.findWhere(definitions, {attr: 'remitto.name'});
491
492       if (!remitToFields || remitToFields.length === 0) {
493         // no need to try to fetch
494         done();
495         return;
496       }
497
498       var requestDetails = {
499         nameSpace: "XM",
500         type: "RemitTo",
501         id: 1
502       };
503       var callback = function (result) {
504         if (!result || result.isError) {
505           done(result || "Invalid query");
506           return;
507         }
508         // Add the remit to data to the raw
509         // data object
510         rawData.remitto = result.data.data;
511         done();
512       };
513       queryForData(req.session, requestDetails, callback);
514     };
515
516
517     /*
518      Elements can be defined by attr or text
519      {
520        "element": "image",
521        "imageType": "qr",
522        "definition": [{"attr": "billtoName"}],
523        "options": {"x": 200, "y": 180}
524     },
525     {
526        "element": "image",
527        "imageType": "qr",
528        "definition": [{"text": "_invoiceNumber"}],
529        "options": {"x": 0, "y": 195}
530      },
531     */
532     var createQrCodes = function (done) {
533       //
534       // Figure out what images we need to fetch, if any
535       //
536       var allElements = _.flatten(_.union(reportDefinition.headerElements,
537           reportDefinition.detailElements, reportDefinition.footerElements)),
538         allQrElements = _.unique(_.pluck(_.where(allElements, {element: "image", imageType: "qr"}), "definition")),
539         marriedQrElements = _.map(allQrElements, function (el) {
540           return {
541             source: el[0].attr || el[0].text,
542             target: marryData(el, reportData[0])[0].data
543           };
544         });
545         // thanks Jeremy
546
547       if (allQrElements.length === 0) {
548         // no need to try to fetch no images
549         done();
550         return;
551       }
552
553       async.each(marriedQrElements, function (element, next) {
554         var target = element.target.substring(0, 5);
555         imageFilenameMap[element.source] = target + ".png";
556
557         // here's the actual qr code code, which requires node 10
558         //var qr = require('qr-image');
559         //var qr_svg = qr.image('I love QR!', { type: 'png' });
560         //qr_svg.pipe(require('fs').createWriteStream('i_love_qr.png'));
561
562         // here's the placeholder code that serves as a proof of concept
563         var sourceFile = path.join(__dirname, "../../i_love_qr.png");
564         fs.readFile(sourceFile, function (err, contents) {
565           fs.writeFile(path.join(workingDir, target + ".png"), contents, function (err) {
566             next();
567           });
568         });
569         // end placeholder code
570
571       }, done);
572     };
573
574     /**
575       Fetch all the translatable strings in the user's language for use
576       when we render.
577       XXX cribbed from locale route
578       TODO: these could be cached
579      */
580     var fetchTranslations = function (done) {
581       var sql = 'select xt.post($${"nameSpace":"XT","type":"Session",' +
582          '"dispatch":{"functionName":"locale","parameters":null},"username":"%@"}$$)'
583          .f(req.session.passport.user.username),
584         org = req.session.passport.user.organization,
585         queryOptions = XT.dataSource.getAdminCredentials(org),
586         dataObj;
587
588       XT.dataSource.query(sql, queryOptions, function (err, results) {
589         var localeObj;
590         if (err) {
591           done(err);
592           return;
593         }
594         localeObj = JSON.parse(results.rows[0].post);
595         // the translations come back in an array, with one object per extension.
596         // cram them all into one object
597         translations = _.reduce(localeObj.strings, function (memo, extStrings) {
598           return _.extend(memo, extStrings);
599         }, {});
600         done();
601       });
602     };
603
604     /**
605       Get the data for this business object.
606       TODO: support lists (i.e. no id)
607      */
608     var fetchData = function (done) {
609       var requestDetails = {
610         nameSpace: req.query.nameSpace,
611         type: req.query.type,
612         id: req.query.id
613       };
614       var callback = function (result) {
615         if (!result || result.isError) {
616           done(result || "Invalid query");
617           return;
618         }
619         rawData = _.extend(rawData, result.data.data);
620         if (auxilliaryInfo) {
621           rawData = _.extend(rawData, JSON.parse(auxilliaryInfo));
622         }
623         reportData = transformDataStructure(rawData);
624         //console.log(reportData);
625         done();
626       };
627
628       queryForData(req.session, requestDetails, callback);
629     };
630
631     /**
632       Generate the report by calling fluentReports.
633      */
634     var printReport = function (done) {
635
636       var printHeader = function (report, data) {
637         printDefinition(report, data, reportDefinition.headerElements);
638       };
639
640       var printDetail = function (report, data) {
641         if (reportDefinition.settings.pageBreakDetail) {
642           // TODO: don't want to break after the last page
643           reportDefinition.detailElements.push({element: "newPage"});
644         }
645         printDefinition(report, data, reportDefinition.detailElements);
646       };
647
648       var printFooter = function (report, data) {
649         printDefinition(report, data, reportDefinition.footerElements);
650       };
651
652       var printPageFooter = function (report, data) {
653         printDefinition(report, data, reportDefinition.pageFooterElements);
654       };
655
656       var rpt = new Report(reportPath)
657         .data(reportData)
658         .detail(printDetail)
659         .pageFooter(printPageFooter)
660         .fontSize(reportDefinition.settings.defaultFontSize)
661         .margins(reportDefinition.settings.defaultMarginSize);
662
663       rpt.groupBy(req.query.id)
664         .header(printHeader)
665         .footer(printFooter);
666
667       rpt.render(done);
668     };
669
670     /**
671       Dispatch the report however the client wants it
672         -Email
673         -Silent Print
674         -Stream download
675         -Display to browser
676     */
677     var sendReport = function (done) {
678       fs.readFile(reportPath, function (err, data) {
679         if (err) {
680           res.send({isError: true, error: err});
681           return;
682         }
683         // Send the appropriate response back the client
684         responseFunctions[req.query.action || "display"](res, data, done);
685       });
686     };
687
688     /**
689      TODO
690      Do we want to clean these all up every time? Do we want to cache them? Do we want to worry
691      about files getting left there if the route crashes before cleanup?
692      */
693     var cleanUpFiles = function (done) {
694       // TODO
695       done();
696     };
697
698     //
699     // Actually perform the operations, one at a time
700     //
701
702     async.series([
703       createTempDir,
704       createTempOrgDir,
705       fetchReportDefinition,
706       fetchRemitTo,
707       fetchTranslations,
708       fetchData,
709       fetchImages,
710       createQrCodes,
711       printReport,
712       sendReport,
713       cleanUpFiles
714     ], function (err, results) {
715       if (err) {
716         res.send({isError: true, message: err.description});
717       }
718     });
719   };
720
721   exports.generateReport = generateReport;
722
723 }());