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 */
12 var _ = require("underscore"),
13 async = require("async"),
15 path = require("path"),
17 Report = require('fluentreports').Report,
18 queryForData = require("./export").queryForData;
21 Generates a report using fluentReports
23 @property req.query.nameSpace
24 @property req.query.type
25 @property req.query.id
26 @property req.query.action
29 https://localhost:8443/dev/generate-report?nameSpace=XM&type=Invoice&id=60000&action=print
32 var generateReport = function (req, res) {
35 // VARIABLES THAT SPAN MULTIPLE STEPS
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 = {},
51 // HELPER FUNCTIONS FOR DATA TRANSFORMATION
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).
58 Fluent expects the data to be just an array, which is the array of the line items with
59 head info copied redundantly.
61 As a convention we'll put a * as prefix in front of the keys of the item data.
63 This function performs both these transformtations on the data object.
65 var transformDataStructure = function (data) {
66 // TODO: detailAttribute could be inferred by looking at whatever comes before the *
67 // in the detailElements definition.
69 if (!reportDefinition.settings.detailAttribute) {
70 // no children, so no transformation is necessary
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;
79 return _.extend({}, data, pathedDetail);
84 Helper function to translate strings
86 var loc = function (s) {
87 return translations[s] || s;
91 Resolve the xTuple JSON convention for report element definition to the
92 output expected from fluentReports by swapping in the data fields.
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.
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).
101 var marryData = function (detailDef, data, textOnly) {
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
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) {
113 text = loc(def.label) + ": " + text;
119 // TODO: maybe support any attributes? Right now we ignore all but these three
123 align: def.align || 2 // default to "center"
131 Custom transformations depending on the element descriptions.
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.
138 var transformElementData = function (def, data) {
144 params = marryData(def.definition, data, true);
145 return transformFunctions[def.transform].apply(this, params);
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]) {
155 // we save the images under a different name than they're described in the definition
156 return path.join(workingDir, imageFilenameMap[mapSource]);
159 // these elements are expecting a parameter that is a number, not
160 if (def.element === 'bandLine' || def.element === 'fontSize' ||
161 def.element === 'margins') {
165 // "print" elements (aka the default) only want strings as the definition
166 textOnly = def.element === "print" || !def.element;
168 return marryData(def.definition, data, textOnly);
171 var formatAddress = function (name, address1, address2, address3, city, state, code, country) {
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) ? ' ' : '') +
181 (state && code ? ' ' : '') +
183 address.push(cityStateZip);
185 if (country) { address.push(country); }
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);
196 var formatFullName = function (firstName, lastName, honorific, suffix) {
198 if (honorific) { fullName.push(honorific + ' '); }
199 fullName.push(firstName + ' ' + lastName);
200 if (suffix) { fullName.push(' ' + suffix); }
204 var transformFunctions = {
205 fullname: formatFullName,
206 address: formatAddress,
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.
215 var printDefinition = function (report, data, definition) {
216 _.each(definition, function (def) {
217 var elementData = transformElementData(def, data);
220 // console.log(elementData);
221 report[def.element || "print"](elementData, def.options);
227 // WAYS TO RETURN TO THE USER
231 Stream the pdf to the browser (on a separate tab, presumably)
233 var responseDisplay = function (res, data, done) {
234 res.header("Content-Type", "application/pdf");
240 Stream the pdf to the user as a download
242 var responseDownload = function (res, data, done) {
243 res.header("Content-Type", "application/pdf");
244 res.attachment(reportPath);
252 var responseEmail = function (res, data, done) {
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);
263 if (!emailProfileId) {
264 done({isError: true, message: "Error: no email profile associated with customer"});
267 emailProfile = new SYS.CustomerEmailProfile();
268 emailProfile.on("statusChange", statusChanged);
271 database: databaseName,
276 var sendEmail = function (done) {
277 var formattedContent = {},
278 callback = function (error, response) {
280 X.log("Email error", error);
281 res.send({isError: true, message: "Error emailing"});
284 res.send({message: "Email success"});
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);
296 formattedContent.text = formattedContent.body;
297 formattedContent.attachments = [{fileName: reportPath, contents: data, contentType: "application/pdf"}];
299 X.smtpTransport.sendMail(formattedContent, callback);
309 Silent-print to a printer registered in the node-datasource.
311 var responsePrint = function (res, data, done) {
312 var printer = ipp.Printer(X.options.datasource.printer),
314 "operation-attributes-tag": {
315 "job-name": "Silent Print",
316 "document-format": "application/pdf"
321 printer.execute("Print-Job", msg, function (error, result) {
323 X.log("Print error", error);
324 res.send({isError: true, message: "Error printing"});
327 res.send({message: "Print Success"});
333 // Convenience hash to avoid if-else
334 var responseFunctions = {
335 display: responseDisplay,
336 download: responseDownload,
337 email: responseEmail,
343 // STEPS TO PERFORM ROUTE
347 Make a directory node-datasource/temp if none exists
349 var createTempDir = function (done) {
350 fs.exists("./temp", function (exists) {
354 fs.mkdir("./temp", done);
360 Make a directory node-datasource/temp/orgname if none exists
362 var createTempOrgDir = function (done) {
363 fs.exists("./temp/" + databaseName, function (exists) {
367 fs.mkdir("./temp/" + databaseName, done);
373 Fetch the highest-grade report definition for this business object.
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"));
383 done({description: "Report Definition not found."});
390 reportDefinitionColl.on("statusChange", afterFetch);
391 reportDefinitionColl.fetch({
394 attribute: "recordType",
395 value: req.query.nameSpace + "." + req.query.type
403 database: databaseName,
409 // Helper function for fetchImages
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);
420 fileCollection.on("statusChange", afterFetch);
421 fileCollection.fetch({
429 database: databaseName,
435 // Helper function for writing image
437 var writeImageToFilesystem = function (fileModel, done) {
438 // XXX this might be an expensive synchronous operation
439 var buffer = new Buffer(fileModel.get("data"));
441 imageFilenameMap[fileModel.get("name")] = fileModel.get("description");
442 fs.writeFile(path.join(workingDir, fileModel.get("description")), buffer, done);
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.
450 var fetchImages = function (done) {
452 // Figure out what images we need to fetch, if any
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";
461 if (allImages.length === 0) {
462 // no need to try to fetch no images
468 // TODO: use the working dir as a cache
474 queryDatabaseForImages(allImages, function (err, fileCollection) {
476 // Write the images to the filesystem
479 async.map(fileCollection.models, writeImageToFilesystem, done);
484 Fetch the remit to name and address information, but only if it
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'});
492 if (!remitToFields || remitToFields.length === 0) {
493 // no need to try to fetch
498 var requestDetails = {
503 var callback = function (result) {
504 if (!result || result.isError) {
505 done(result || "Invalid query");
508 // Add the remit to data to the raw
510 rawData.remitto = result.data.data;
513 queryForData(req.session, requestDetails, callback);
518 Elements can be defined by attr or text
522 "definition": [{"attr": "billtoName"}],
523 "options": {"x": 200, "y": 180}
528 "definition": [{"text": "_invoiceNumber"}],
529 "options": {"x": 0, "y": 195}
532 var createQrCodes = function (done) {
534 // Figure out what images we need to fetch, if any
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) {
541 source: el[0].attr || el[0].text,
542 target: marryData(el, reportData[0])[0].data
547 if (allQrElements.length === 0) {
548 // no need to try to fetch no images
553 async.each(marriedQrElements, function (element, next) {
554 var target = element.target.substring(0, 5);
555 imageFilenameMap[element.source] = target + ".png";
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'));
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) {
569 // end placeholder code
575 Fetch all the translatable strings in the user's language for use
577 XXX cribbed from locale route
578 TODO: these could be cached
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),
588 XT.dataSource.query(sql, queryOptions, function (err, results) {
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);
605 Get the data for this business object.
606 TODO: support lists (i.e. no id)
608 var fetchData = function (done) {
609 var requestDetails = {
610 nameSpace: req.query.nameSpace,
611 type: req.query.type,
614 var callback = function (result) {
615 if (!result || result.isError) {
616 done(result || "Invalid query");
619 rawData = _.extend(rawData, result.data.data);
620 if (auxilliaryInfo) {
621 rawData = _.extend(rawData, JSON.parse(auxilliaryInfo));
623 reportData = transformDataStructure(rawData);
624 //console.log(reportData);
628 queryForData(req.session, requestDetails, callback);
632 Generate the report by calling fluentReports.
634 var printReport = function (done) {
636 var printHeader = function (report, data) {
637 printDefinition(report, data, reportDefinition.headerElements);
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"});
645 printDefinition(report, data, reportDefinition.detailElements);
648 var printFooter = function (report, data) {
649 printDefinition(report, data, reportDefinition.footerElements);
652 var printPageFooter = function (report, data) {
653 printDefinition(report, data, reportDefinition.pageFooterElements);
656 var rpt = new Report(reportPath)
659 .pageFooter(printPageFooter)
660 .fontSize(reportDefinition.settings.defaultFontSize)
661 .margins(reportDefinition.settings.defaultMarginSize);
663 rpt.groupBy(req.query.id)
665 .footer(printFooter);
671 Dispatch the report however the client wants it
677 var sendReport = function (done) {
678 fs.readFile(reportPath, function (err, data) {
680 res.send({isError: true, error: err});
683 // Send the appropriate response back the client
684 responseFunctions[req.query.action || "display"](res, data, done);
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?
693 var cleanUpFiles = function (done) {
699 // Actually perform the operations, one at a time
705 fetchReportDefinition,
714 ], function (err, results) {
716 res.send({isError: true, message: err.description});
721 exports.generateReport = generateReport;