diff --git a/package-lock.json b/package-lock.json index 0e07a0a..5f591e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "erp-backend", "version": "1.0.0", + "hasInstallScript": true, "license": "ISC", "dependencies": { "@prisma/client": "^5.22.0", @@ -22,6 +23,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", "multer": "^1.4.5-lts.2", + "puppeteer": "^25.2.0", "swagger-jsdoc": "6.2.8", "swagger-ui-express": "^5.0.1", "uuid": "^9.0.1", @@ -1362,6 +1364,91 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.5.tgz", + "integrity": "sha512-xYXNuEQmHNIPWWcbL/skf2KF7seyp7c1xmKFRk3wmdFx7VwBsKVrtOLKs8ecaezsKPsWeF1YsgwIiElAscaryA==", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -2190,6 +2277,21 @@ "node": ">=10" } }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -2667,6 +2769,11 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1638949", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz", + "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -2755,8 +2862,7 @@ "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" }, "node_modules/enabled": { "version": "2.0.0", @@ -2838,7 +2944,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "engines": { "node": ">=6" } @@ -3504,7 +3609,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -3513,7 +3617,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, "engines": { "node": ">=18" }, @@ -4848,7 +4951,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "engines": { "node": ">=14" }, @@ -5483,6 +5585,11 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -5494,6 +5601,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -6189,6 +6304,42 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.2.0.tgz", + "integrity": "sha512-JPMPd/2+lgdkLhEyPqH895oR3ccMt1wSra6oewgjjTuLmo2s9zPZpKXQTFEIiA/fMKpiL01kjU3+2zPEReRWNg==", + "hasInstallScript": true, + "dependencies": { + "@puppeteer/browsers": "3.0.5", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.2.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.2.0.tgz", + "integrity": "sha512-jGhuGAlkgOcbyGRc0Cm9b/y4vvqoxhyAyl6a1diVe8F3sHsgTaQ60QQT5F3rGegTZV3prysgHVc+0LsvPZo3GA==", + "dependencies": { + "@puppeteer/browsers": "3.0.5", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -6817,7 +6968,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -6834,7 +6984,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "engines": { "node": ">=12" }, @@ -6846,7 +6995,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, "dependencies": { "ansi-regex": "^6.2.2" }, @@ -7247,6 +7395,11 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -7376,6 +7529,11 @@ "makeerror": "1.0.12" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -7529,7 +7687,6 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", @@ -7546,7 +7703,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "engines": { "node": ">=12" }, @@ -7558,7 +7714,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "engines": { "node": ">=12" }, @@ -7570,7 +7725,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, "dependencies": { "ansi-regex": "^6.2.2" }, @@ -7599,6 +7753,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -7611,7 +7785,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "engines": { "node": ">=10" } @@ -7732,6 +7905,14 @@ "engines": { "node": "^12.20.0 || >=14" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a9586b6..080a1b7 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "node prisma/seed.js", + "pdf:preview": "node scripts/preview-pdf-templates.js", "prepare": "husky install" }, "lint-staged": { @@ -37,6 +38,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", "multer": "^1.4.5-lts.2", + "puppeteer": "^25.2.0", "swagger-jsdoc": "6.2.8", "swagger-ui-express": "^5.0.1", "uuid": "^9.0.1", diff --git a/scripts/preview-pdf-templates.js b/scripts/preview-pdf-templates.js new file mode 100644 index 0000000..4cfc634 --- /dev/null +++ b/scripts/preview-pdf-templates.js @@ -0,0 +1,32 @@ +const fs = require('fs'); +const path = require('path'); +const { generatePdf } = require('../src/utils/pdf/pdfGenerator'); +const { generatePoHtml } = require('../src/utils/pdf/templates/po.template'); +const { generateGrnHtml } = require('../src/utils/pdf/templates/grn.template'); +const { generateAssetHtml } = require('../src/utils/pdf/templates/asset.template'); +const { generateAmcHtml } = require('../src/utils/pdf/templates/amc.template'); + +const OUTPUT_DIR = path.join(__dirname, '..', 'uploads', 'pdf-previews'); + +const templates = [ + { name: 'purchase-order', html: generatePoHtml() }, + { name: 'grn', html: generateGrnHtml() }, + { name: 'asset', html: generateAssetHtml() }, + { name: 'amc', html: generateAmcHtml() }, +]; + +const run = async () => { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + + for (const template of templates) { + const outputPath = path.join(OUTPUT_DIR, `${template.name}.pdf`); + const pdfBuffer = await generatePdf(template.html); + fs.writeFileSync(outputPath, pdfBuffer); + console.log(`Generated: ${outputPath}`); + } +}; + +run().catch((error) => { + console.error('PDF preview generation failed:', error.message); + process.exit(1); +}); diff --git a/src/middlewares/error.middleware.js b/src/middlewares/error.middleware.js index 7b617ae..a94264b 100644 --- a/src/middlewares/error.middleware.js +++ b/src/middlewares/error.middleware.js @@ -1,31 +1,52 @@ const ApiError = require('../utils/ApiError'); const logger = require('../config/logger'); const env = require('../config/env'); +const { mapDbError } = require('../utils/mapDbError'); module.exports = (err, req, res, _next) => { - let statusCode = err.statusCode || 500; - let message = err.message || 'Internal server error'; - let errors = err.errors || []; + const mapped = mapDbError(err); + const isOperational = err instanceof ApiError || mapped instanceof ApiError; - if (!(err instanceof ApiError)) { - statusCode = 500; - message = env.NODE_ENV === 'production' ? 'Internal server error' : err.message; - errors = []; + let statusCode = 500; + let message = 'Internal server error'; + let errors = []; + + if (mapped instanceof ApiError) { + statusCode = mapped.statusCode; + message = mapped.message; + errors = mapped.errors || []; + } else if (err instanceof ApiError) { + statusCode = err.statusCode; + message = err.message; + errors = err.errors || []; + } else if (env.NODE_ENV !== 'production') { + message = err.message || message; } - logger.error(message, { + logger.error(isOperational ? message : err.message || message, { requestId: req.id, statusCode, path: req.originalUrl, method: req.method, - user: req.user ? req.user.id.toString() : undefined, - stack: err.stack, + user: req.user?.id?.toString(), + errorName: err?.name, + prismaCode: err?.code, + pgCode: err?.cause?.code, + constraint: err?.cause?.constraint, + prismaMeta: err?.meta, + clientMessage: message, + stack: err?.stack, }); - return res.status(statusCode).json({ + const payload = { success: false, message, errors, - ...(env.NODE_ENV === 'development' && { stack: err.stack }), - }); + }; + + if (env.NODE_ENV === 'development' && !isOperational && err?.stack) { + payload.stack = err.stack; + } + + return res.status(statusCode).json(payload); }; diff --git a/src/modules/masters/brands/brands.validation.js b/src/modules/masters/brands/brands.validation.js index d49e00c..fd08da6 100644 --- a/src/modules/masters/brands/brands.validation.js +++ b/src/modules/masters/brands/brands.validation.js @@ -1,16 +1,22 @@ const Joi = require('joi'); +const BRAND_TYPES = ['OWN', 'OEM', 'THIRD_PARTY']; + const createSchema = Joi.object({ - code: Joi.string().allow('').optional(), - name: Joi.string().allow('').optional(), - brand_type: Joi.string().allow('').optional(), - contact_person: Joi.string().allow('').optional(), - phone: Joi.string().allow('').optional(), - email: Joi.string().allow('').optional(), - is_active: Joi.boolean().optional(), + code: Joi.string().max(30).required(), + name: Joi.string().max(150).required(), + brand_type: Joi.string() + .valid(...BRAND_TYPES) + .default('OWN'), + contact_person: Joi.string().max(100).allow(null, '').optional(), + phone: Joi.string().max(20).allow(null, '').optional(), + email: Joi.string().email().max(200).allow(null, '').optional(), + is_active: Joi.boolean().default(true), }); -const updateSchema = createSchema.min(1); +const updateSchema = createSchema + .fork(['code', 'name', 'brand_type'], (schema) => schema.optional()) + .min(1); const listQuerySchema = Joi.object({ page: Joi.number().integer().min(1).default(1), @@ -19,4 +25,4 @@ const listQuerySchema = Joi.object({ is_active: Joi.boolean().optional(), }); -module.exports = { createSchema, updateSchema, listQuerySchema }; +module.exports = { createSchema, updateSchema, listQuerySchema, BRAND_TYPES }; diff --git a/src/utils/mapDbError.js b/src/utils/mapDbError.js new file mode 100644 index 0000000..5cd5b5c --- /dev/null +++ b/src/utils/mapDbError.js @@ -0,0 +1,113 @@ +const ApiError = require('./ApiError'); + +const PG_CODES = { + UNIQUE_VIOLATION: '23505', + FOREIGN_KEY_VIOLATION: '23503', + CHECK_VIOLATION: '23514', + NOT_NULL_VIOLATION: '23502', +}; + +const CONSTRAINT_MESSAGES = { + brands_brand_type_check: 'Invalid brand type. Allowed values: OWN, OEM, THIRD_PARTY.', +}; + +const TABLE_MESSAGES = { + brands: 'Invalid brand data. Please check required fields and allowed values.', +}; + +const extractPgCode = (err) => { + const fromCause = err?.cause?.code; + if (fromCause) return String(fromCause); + + const match = String(err?.message || '').match(/code: "(\d{5})"/); + return match ? match[1] : null; +}; + +const extractConstraint = (err) => { + const fromCause = err?.cause?.constraint; + if (fromCause) return fromCause; + + const match = String(err?.message || '').match(/constraint "([^"]+)"/i); + return match ? match[1] : null; +}; + +const extractTable = (err) => { + const fromCause = err?.cause?.table; + if (fromCause) return fromCause; + + const match = String(err?.message || '').match(/relation "([^"]+)"/i); + return match ? match[1] : null; +}; + +const mapPrismaKnownError = (err) => { + switch (err.code) { + case 'P2002': { + const target = err.meta?.target; + const fields = Array.isArray(target) ? target.join(', ') : 'value'; + return new ApiError(409, `A record with this ${fields} already exists`); + } + case 'P2003': + return new ApiError(422, 'Referenced record does not exist or is invalid'); + case 'P2025': + return new ApiError(404, 'Record not found'); + default: + return null; + } +}; + +const mapPostgresError = (err) => { + const pgCode = extractPgCode(err); + const constraint = extractConstraint(err); + const table = extractTable(err); + + if (constraint && CONSTRAINT_MESSAGES[constraint]) { + return new ApiError(422, CONSTRAINT_MESSAGES[constraint]); + } + + if (pgCode === PG_CODES.UNIQUE_VIOLATION) { + return new ApiError(409, 'A record with this value already exists'); + } + + if (pgCode === PG_CODES.FOREIGN_KEY_VIOLATION) { + return new ApiError(422, 'Referenced record does not exist or is invalid'); + } + + if (pgCode === PG_CODES.NOT_NULL_VIOLATION) { + return new ApiError(422, 'A required field is missing'); + } + + if (pgCode === PG_CODES.CHECK_VIOLATION) { + if (table && TABLE_MESSAGES[table]) { + return new ApiError(422, TABLE_MESSAGES[table]); + } + return new ApiError(422, 'Invalid value. Please check allowed options and required fields.'); + } + + return null; +}; + +const mapDbError = (err) => { + if (err instanceof ApiError) return err; + if (!err) return null; + + if (err.name === 'PrismaClientKnownRequestError' || err.code?.startsWith?.('P')) { + const mapped = mapPrismaKnownError(err); + if (mapped) return mapped; + } + + if ( + err.name === 'PrismaClientUnknownRequestError' || + err.name === 'PrismaClientRustPanicError' || + extractPgCode(err) + ) { + const mapped = mapPostgresError(err); + if (mapped) return mapped; + } + + return null; +}; + +module.exports = { + mapDbError, + PG_CODES, +}; diff --git a/src/utils/pdf/helpers/formatCurrency.js b/src/utils/pdf/helpers/formatCurrency.js new file mode 100644 index 0000000..b23f65a --- /dev/null +++ b/src/utils/pdf/helpers/formatCurrency.js @@ -0,0 +1,12 @@ +const formatCurrency = (value = 0) => { + const amount = Number(value) || 0; + + return new Intl.NumberFormat('en-IN', { + style: 'currency', + currency: 'INR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +}; + +module.exports = { formatCurrency }; diff --git a/src/utils/pdf/helpers/formatDate.js b/src/utils/pdf/helpers/formatDate.js new file mode 100644 index 0000000..e04a20d --- /dev/null +++ b/src/utils/pdf/helpers/formatDate.js @@ -0,0 +1,16 @@ +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +const formatDate = (value) => { + if (!value) return '-'; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + + const day = String(date.getDate()).padStart(2, '0'); + const month = MONTHS[date.getMonth()]; + const year = date.getFullYear(); + + return `${day}-${month}-${year}`; +}; + +module.exports = { formatDate }; diff --git a/src/utils/pdf/helpers/numberToWords.js b/src/utils/pdf/helpers/numberToWords.js new file mode 100644 index 0000000..b87cb7c --- /dev/null +++ b/src/utils/pdf/helpers/numberToWords.js @@ -0,0 +1,76 @@ +const ONES = [ + '', + 'One', + 'Two', + 'Three', + 'Four', + 'Five', + 'Six', + 'Seven', + 'Eight', + 'Nine', + 'Ten', + 'Eleven', + 'Twelve', + 'Thirteen', + 'Fourteen', + 'Fifteen', + 'Sixteen', + 'Seventeen', + 'Eighteen', + 'Nineteen', +]; + +const TENS = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety']; + +const twoDigitsToWords = (num) => { + if (num < 20) return ONES[num]; + + const ten = Math.floor(num / 10); + const one = num % 10; + return `${TENS[ten]}${one ? ` ${ONES[one]}` : ''}`.trim(); +}; + +const threeDigitsToWords = (num) => { + const hundred = Math.floor(num / 100); + const remainder = num % 100; + + const hundredPart = hundred ? `${ONES[hundred]} Hundred` : ''; + const remainderPart = remainder ? twoDigitsToWords(remainder) : ''; + + return `${hundredPart}${hundredPart && remainderPart ? ' ' : ''}${remainderPart}`.trim(); +}; + +const integerToIndianWords = (num) => { + if (num === 0) return 'Zero'; + + const crore = Math.floor(num / 10000000); + const lakh = Math.floor((num % 10000000) / 100000); + const thousand = Math.floor((num % 100000) / 1000); + const rest = num % 1000; + + const chunks = []; + if (crore) chunks.push(`${twoDigitsToWords(crore)} Crore`); + if (lakh) chunks.push(`${twoDigitsToWords(lakh)} Lakh`); + if (thousand) chunks.push(`${twoDigitsToWords(thousand)} Thousand`); + if (rest) chunks.push(threeDigitsToWords(rest)); + + return chunks.join(' ').trim(); +}; + +const numberToWords = (value = 0) => { + const amount = Number(value) || 0; + const integerPart = Math.floor(amount); + const decimalPart = Math.round((amount - integerPart) * 100); + + const rupeesWords = integerToIndianWords(integerPart); + const paiseWords = decimalPart ? twoDigitsToWords(decimalPart) : ''; + + if (decimalPart) { + return `${rupeesWords} Rupees and ${paiseWords} Paise Only`; + } + + return `${rupeesWords} Rupees Only`; +}; + +module.exports = { numberToWords }; diff --git a/src/utils/pdf/helpers/templateBase.js b/src/utils/pdf/helpers/templateBase.js new file mode 100644 index 0000000..2d6f6c9 --- /dev/null +++ b/src/utils/pdf/helpers/templateBase.js @@ -0,0 +1,258 @@ +const escapeHtml = (value = '') => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const getCompanyInitials = (name = '') => + String(name) + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0].toUpperCase()) + .join('') || 'CO'; + +const getBaseStyles = () => ` + * { box-sizing: border-box; } + body { + margin: 0; + font-family: Arial, Helvetica, sans-serif; + color: #111827; + background: #ffffff; + font-size: 12px; + line-height: 1.5; + } + .document { width: 100%; padding: 0; } + .header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + } + .brand { + display: flex; + gap: 12px; + align-items: flex-start; + flex: 1; + } + .logo { + width: 56px; + height: 56px; + border-radius: 8px; + background: #1d4ed8; + color: #ffffff; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + letter-spacing: 1px; + } + .company h1 { + margin: 0; + font-size: 16px; + color: #1d4ed8; + } + .company .meta { + margin-top: 4px; + color: #374151; + } + .title-block { + text-align: right; + min-width: 280px; + } + .title { + margin: 0; + color: #1d4ed8; + font-size: 28px; + font-weight: 800; + letter-spacing: 0.7px; + } + .doc-meta { + margin-top: 6px; + border: 1px solid #bfdbfe; + border-radius: 8px; + padding: 8px 10px; + background: #eff6ff; + } + .status { + display: inline-block; + background: #1d4ed8; + color: #ffffff; + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + border-radius: 10px; + } + .party-grid { + margin-top: 14px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + .card { + border: 1px solid #dbeafe; + border-radius: 8px; + overflow: hidden; + } + .card-title { + background: #1d4ed8; + color: #ffffff; + padding: 8px 10px; + font-weight: 700; + } + .card-body { padding: 10px; } + table { + width: 100%; + border-collapse: collapse; + } + .items { + margin-top: 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + overflow: hidden; + } + .items thead th { + background: #1d4ed8; + color: #ffffff; + padding: 8px 6px; + font-size: 11px; + text-align: left; + } + .items tbody td { + padding: 8px 6px; + border-bottom: 1px solid #e5e7eb; + } + .items tbody tr:nth-child(even) { background: #f9fafb; } + .text-right { text-align: right; } + .item-name { width: 34%; } + .info-grid { + margin-top: 12px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + .info-row { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 4px 0; + border-bottom: 1px dashed #e5e7eb; + } + .info-row:last-child { border-bottom: none; } + .info-label { + color: #6b7280; + min-width: 130px; + } + .info-value { + font-weight: 600; + text-align: right; + } + .summary-wrap { + margin-top: 12px; + display: flex; + justify-content: flex-end; + } + .summary-box { + width: 340px; + border: 1px solid #dbeafe; + border-radius: 8px; + overflow: hidden; + } + .summary-box table td { padding: 8px 10px; } + .summary-box tr:nth-child(even) { background: #f9fafb; } + .grand-total { + font-size: 16px; + font-weight: 800; + color: #1d4ed8; + } + .amount-words { + margin-top: 8px; + border: 1px dashed #93c5fd; + border-radius: 8px; + padding: 8px 10px; + font-weight: 600; + color: #1e40af; + } + .terms { + margin-top: 14px; + border: 1px solid #dbeafe; + border-radius: 8px; + padding: 10px 12px; + } + .terms h3 { + margin: 0 0 8px; + color: #1d4ed8; + font-size: 14px; + } + .footnote { + margin-top: 16px; + display: flex; + justify-content: space-between; + color: #6b7280; + font-size: 10px; + } + .page-number:before { + content: "Page " counter(page); + } +`; + +const renderCompanyHeader = ({ company, title, docMetaHtml }) => { + const initials = getCompanyInitials(company.name); + + return ` +
+
+ +
+

${escapeHtml(company.name)}

+
+ ${escapeHtml(company.address)}, ${escapeHtml(company.city)}, ${escapeHtml(company.state)} - ${escapeHtml(company.pincode)}
+ GSTIN: ${escapeHtml(company.gstin)} | Phone: ${escapeHtml(company.phone)}
+ Email: ${escapeHtml(company.email)} +
+
+
+
+

${escapeHtml(title)}

+
${docMetaHtml}
+
+
+ `; +}; + +const renderPartyCard = (title, bodyHtml) => ` +
+
${escapeHtml(title)}
+
${bodyHtml}
+
+`; + +const renderFootnote = () => ` +
+ This is a computer generated document. + +
+`; + +const getDummyCompany = () => ({ + name: 'Acme Infra & Engineering Pvt. Ltd.', + address: '3rd Floor, Business Tower, Sector 62', + city: 'Noida', + state: 'Uttar Pradesh', + pincode: '201301', + gstin: '09AABCA1234K1Z7', + phone: '+91 120 4400 221', + email: 'procurement@acmeinfra.in', +}); + +module.exports = { + escapeHtml, + getBaseStyles, + renderCompanyHeader, + renderPartyCard, + renderFootnote, + getDummyCompany, + getCompanyInitials, +}; diff --git a/src/utils/pdf/pdfGenerator.js b/src/utils/pdf/pdfGenerator.js new file mode 100644 index 0000000..4fae3e8 --- /dev/null +++ b/src/utils/pdf/pdfGenerator.js @@ -0,0 +1,24 @@ +const generatePdf = async (htmlContent) => { + const puppeteer = await import('puppeteer'); + const browser = await puppeteer.default.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + try { + const page = await browser.newPage(); + await page.setContent(htmlContent, { waitUntil: 'networkidle0' }); + + const pdfBuffer = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' }, + }); + + return pdfBuffer; + } finally { + await browser.close(); + } +}; + +module.exports = { generatePdf }; diff --git a/src/utils/pdf/templates/amc.template.js b/src/utils/pdf/templates/amc.template.js new file mode 100644 index 0000000..3efd9c7 --- /dev/null +++ b/src/utils/pdf/templates/amc.template.js @@ -0,0 +1,191 @@ +const { formatCurrency } = require('../helpers/formatCurrency'); +const { formatDate } = require('../helpers/formatDate'); +const { numberToWords } = require('../helpers/numberToWords'); +const { + escapeHtml, + getBaseStyles, + renderCompanyHeader, + renderPartyCard, + renderFootnote, + getDummyCompany, +} = require('../helpers/templateBase'); + +const getDummyAmcData = () => ({ + company: getDummyCompany(), + asset: { + asset_code: 'AST-PLT-2026-0042', + asset_name: 'CNC Lathe Machine - Model XL-500', + brand_model: 'XL-500 Pro Series', + serial_number: 'PTI-XL500-2024-8891', + plant_name: 'Noida Manufacturing Plant', + department_name: 'Production Engineering', + }, + contract: { + contract_no: 'AMC-2026-PTI-0042', + contract_type: 'COMPREHENSIVE', + start_date: '2026-04-01', + end_date: '2027-03-31', + renewal_date: '2027-02-15', + annual_cost: 125000, + payment_frequency: 'ANNUAL', + service_frequency: 'QUARTERLY', + visits_per_year: 4, + is_active: true, + contact_person: 'Suresh Kulkarni', + contact_phone: '+91 98765 43210', + contact_email: 'amc.support@precisiontools.in', + scope_of_work: + 'Preventive maintenance, breakdown support, spare parts labour, and annual health check of CNC lathe including spindle, coolant system, and control panel.', + exclusions: + 'Major spindle replacement, accidental damage, consumables (coolant, lubricants), and third-party calibration charges.', + remarks: 'Response SLA: 24 hours for breakdown calls. Next scheduled visit due 15-Jul-2026.', + }, + vendor: { + vendor_code: 'VND-PTI-01', + vendor_name: 'Precision Tools India', + address: '12, Industrial Area, Pimpri', + city: 'Pune', + state: 'Maharashtra', + pincode: '411018', + gstin: '27AABCP1234M1Z5', + }, + service_visits: [ + { + visit_date: '2026-04-10', + visit_type: 'PREVENTIVE', + status: 'COMPLETED', + engineer_name: 'Anil Deshmukh', + work_done: 'Quarterly PM — lubrication, belt tension check, control panel inspection.', + }, + { + visit_date: '2026-01-08', + visit_type: 'BREAKDOWN', + status: 'COMPLETED', + engineer_name: 'Rohit Jadhav', + work_done: 'Replaced coolant pump seal and recalibrated spindle speed sensor.', + }, + ], +}); + +const generateAmcHtml = (inputData = getDummyAmcData()) => { + const data = inputData || getDummyAmcData(); + + const visitRows = (data.service_visits || []) + .map( + (visit, index) => ` + + ${index + 1} + ${formatDate(visit.visit_date)} + ${escapeHtml(visit.visit_type)} + ${escapeHtml(visit.status)} + ${escapeHtml(visit.engineer_name)} + ${escapeHtml(visit.work_done)} + + ` + ) + .join(''); + + const docMetaHtml = ` +
Contract No: ${escapeHtml(data.contract.contract_no)}
+
Valid From: ${formatDate(data.contract.start_date)}
+
Valid To: ${formatDate(data.contract.end_date)}
+
${data.contract.is_active ? 'ACTIVE' : 'INACTIVE'}
+ `; + + return ` + + + + + AMC Contract Summary + + + +
+ ${renderCompanyHeader({ company: data.company, title: 'AMC CONTRACT SUMMARY', docMetaHtml })} + +
+ ${renderPartyCard( + 'Asset Owner', + ` + ${escapeHtml(data.company.name)}
+ Plant: ${escapeHtml(data.asset.plant_name)}
+ Department: ${escapeHtml(data.asset.department_name)} + ` + )} + ${renderPartyCard( + 'AMC Vendor', + ` + ${escapeHtml(data.vendor.vendor_name)} (${escapeHtml(data.vendor.vendor_code)})
+ ${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}
+ ${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}
+ GSTIN: ${escapeHtml(data.vendor.gstin)}
+ Contact: ${escapeHtml(data.contract.contact_person)} (${escapeHtml(data.contract.contact_phone)}) + ` + )} +
+ +
+
Covered Asset
+
+ ${escapeHtml(data.asset.asset_name)} (${escapeHtml(data.asset.asset_code)})
+ ${escapeHtml(data.asset.brand_model)} | S/N: ${escapeHtml(data.asset.serial_number)} +
+
+ +
+
+
Contract Terms
+
+
Contract Type${escapeHtml(data.contract.contract_type)}
+
Payment Frequency${escapeHtml(data.contract.payment_frequency)}
+
Service Frequency${escapeHtml(data.contract.service_frequency)}
+
Visits Per Year${escapeHtml(data.contract.visits_per_year)}
+
Renewal Date${formatDate(data.contract.renewal_date)}
+
Annual Cost${formatCurrency(data.contract.annual_cost)}
+
+
+
+
Vendor Contact
+
+
Contact Person${escapeHtml(data.contract.contact_person)}
+
Phone${escapeHtml(data.contract.contact_phone)}
+
Email${escapeHtml(data.contract.contact_email)}
+
Contract Period${formatDate(data.contract.start_date)} to ${formatDate(data.contract.end_date)}
+
+
+
+ +
Annual Cost in Words: ${escapeHtml(numberToWords(data.contract.annual_cost))}
+ +
+

Scope of Work

+
${escapeHtml(data.contract.scope_of_work)}
+

Exclusions

+
${escapeHtml(data.contract.exclusions)}
+

Remarks

+
${escapeHtml(data.contract.remarks)}
+
+ + + + + + + + + + + + + ${visitRows} +
S.NoVisit DateTypeStatusEngineerWork Done
+ + ${renderFootnote()} +
+ + +`; +}; + +module.exports = { generateAmcHtml, getDummyAmcData }; diff --git a/src/utils/pdf/templates/asset.template.js b/src/utils/pdf/templates/asset.template.js new file mode 100644 index 0000000..80e6469 --- /dev/null +++ b/src/utils/pdf/templates/asset.template.js @@ -0,0 +1,179 @@ +const { formatCurrency } = require('../helpers/formatCurrency'); +const { formatDate } = require('../helpers/formatDate'); +const { + escapeHtml, + getBaseStyles, + renderCompanyHeader, + renderFootnote, + getDummyCompany, +} = require('../helpers/templateBase'); + +const getDummyAssetData = () => ({ + company: getDummyCompany(), + asset: { + asset_code: 'AST-PLT-2026-0042', + asset_name: 'CNC Lathe Machine - Model XL-500', + status: 'IN_USE', + condition: 'GOOD', + brand_model: 'XL-500 Pro Series', + manufacturer: 'Precision Tools India', + serial_number: 'PTI-XL500-2024-8891', + part_number: 'XL500-PRO-3AX', + purchase_date: '2024-03-15', + purchase_cost: 1850000, + useful_life_years: 10, + depreciation_method: 'WDV', + salvage_value: 185000, + warranty_expiry_date: '2027-03-14', + location_detail: 'Bay 3, Production Floor', + qr_code_value: 'AST-PLT-2026-0042', + remarks: 'Annual calibration due in September. AMC contract active.', + }, + category: { + code: 'PLT', + name: 'Plant & Machinery', + }, + plant: { + code: 'PLT-NOI-01', + name: 'Noida Manufacturing Plant', + }, + department: { + name: 'Production Engineering', + }, + warehouse: { + code: 'WH-NOI-01', + name: 'Noida Main Warehouse', + }, + assigned_to: { + full_name: 'Vikram Singh', + employee_code: 'EMP-1042', + }, + vendor: { + vendor_code: 'VND-PTI-01', + vendor_name: 'Precision Tools India', + }, + purchase_order: { + po_number: 'PO-2024-00112', + }, + grn: { + grn_number: 'GRN-2024-00089', + }, +}); + +const renderInfoRows = (rows) => + rows + .map( + ([label, value]) => ` +
+ ${escapeHtml(label)} + ${value} +
+ ` + ) + .join(''); + +const generateAssetHtml = (inputData = getDummyAssetData()) => { + const data = inputData || getDummyAssetData(); + + const docMetaHtml = ` +
Asset Code: ${escapeHtml(data.asset.asset_code)}
+
Registered: ${formatDate(data.asset.purchase_date)}
+
Category: ${escapeHtml(data.category.name)}
+
${escapeHtml(data.asset.status)}
+ `; + + return ` + + + + + Asset Detail Sheet + + + +
+ ${renderCompanyHeader({ company: data.company, title: 'ASSET DETAIL SHEET', docMetaHtml })} + +
+
Asset Identification
+
+ ${escapeHtml(data.asset.asset_name)} +
+ ${escapeHtml(data.asset.brand_model)} | S/N: ${escapeHtml(data.asset.serial_number)} +
+
+
+ +
+
+
Asset Details
+
+ ${renderInfoRows([ + ['Asset Code', escapeHtml(data.asset.asset_code)], + ['Category', `${escapeHtml(data.category.code)} - ${escapeHtml(data.category.name)}`], + ['Manufacturer', escapeHtml(data.asset.manufacturer)], + ['Brand / Model', escapeHtml(data.asset.brand_model)], + ['Serial Number', escapeHtml(data.asset.serial_number)], + ['Part Number', escapeHtml(data.asset.part_number)], + ['Condition', escapeHtml(data.asset.condition)], + ['QR Code', escapeHtml(data.asset.qr_code_value)], + ])} +
+
+
+
Location & Assignment
+
+ ${renderInfoRows([ + ['Plant', `${escapeHtml(data.plant.code)} - ${escapeHtml(data.plant.name)}`], + ['Department', escapeHtml(data.department.name)], + ['Warehouse', `${escapeHtml(data.warehouse.code)} - ${escapeHtml(data.warehouse.name)}`], + ['Location Detail', escapeHtml(data.asset.location_detail)], + [ + 'Assigned To', + `${escapeHtml(data.assigned_to.full_name)} (${escapeHtml(data.assigned_to.employee_code)})`, + ], + ])} +
+
+
+ +
+
+
Financial & Depreciation
+
+ ${renderInfoRows([ + ['Purchase Date', formatDate(data.asset.purchase_date)], + ['Purchase Cost', formatCurrency(data.asset.purchase_cost)], + ['Useful Life', `${escapeHtml(data.asset.useful_life_years)} years`], + ['Depreciation Method', escapeHtml(data.asset.depreciation_method)], + ['Salvage Value', formatCurrency(data.asset.salvage_value)], + ['Warranty Expiry', formatDate(data.asset.warranty_expiry_date)], + ])} +
+
+
+
Procurement Reference
+
+ ${renderInfoRows([ + ['Vendor', `${escapeHtml(data.vendor.vendor_code)} - ${escapeHtml(data.vendor.vendor_name)}`], + ['Purchase Order', escapeHtml(data.purchase_order.po_number)], + ['GRN Reference', escapeHtml(data.grn.grn_number)], + ['Status', escapeHtml(data.asset.status)], + ])} +
+
+
+ +
+

Remarks

+
${escapeHtml(data.asset.remarks)}
+
+ + ${renderFootnote()} +
+ + +`; +}; + +module.exports = { generateAssetHtml, getDummyAssetData }; diff --git a/src/utils/pdf/templates/grn.template.js b/src/utils/pdf/templates/grn.template.js new file mode 100644 index 0000000..461fcd0 --- /dev/null +++ b/src/utils/pdf/templates/grn.template.js @@ -0,0 +1,235 @@ +const { formatCurrency } = require('../helpers/formatCurrency'); +const { formatDate } = require('../helpers/formatDate'); +const { numberToWords } = require('../helpers/numberToWords'); +const { + escapeHtml, + getBaseStyles, + renderCompanyHeader, + renderPartyCard, + renderFootnote, + getDummyCompany, +} = require('../helpers/templateBase'); + +const getDummyGrnData = () => ({ + company: getDummyCompany(), + grn: { + grn_number: 'GRN-2026-00018', + grn_date: '2026-06-22', + status: 'POSTED', + po_number: 'PO-2026-00047', + vendor_invoice_no: 'SIS/INV/2026/441', + vendor_invoice_date: '2026-06-21', + vendor_invoice_amount: 254743.5, + vehicle_no: 'MH-12-AB-4521', + lr_no: 'LR-77821', + lr_date: '2026-06-21', + received_by: 'Amit Sharma', + quality_checked_by: 'Priya Nair', + remarks: 'All items inspected and accepted except partial rejection on line 3.', + }, + vendor: { + vendor_name: 'Shree Industrial Supplies', + gstin: '27AAECS7788B1Z2', + address: 'Plot 17, MIDC Industrial Area', + city: 'Pune', + state: 'Maharashtra', + pincode: '411019', + contact_name: 'Rajesh Patil', + phone: '+91 98220 12345', + }, + warehouse: { + code: 'WH-NOI-01', + name: 'Noida Main Warehouse', + address: 'Plot 9, Industrial Estate, Sector 63, Noida', + }, + items: [ + { + line_no: 1, + item_code: 'ITM-MS-CH-100', + item_name: 'MS Channel 100x50 mm', + uom: 'KG', + ordered_qty: 1250, + previously_received_qty: 0, + current_qty: 1250, + accepted_qty: 1250, + rejected_qty: 0, + rate: 72.5, + line_total: 90625, + }, + { + line_no: 2, + item_code: 'ITM-FST-M12', + item_name: 'Industrial Fasteners Set M12', + uom: 'BOX', + ordered_qty: 40, + previously_received_qty: 0, + current_qty: 40, + accepted_qty: 40, + rejected_qty: 0, + rate: 1425, + line_total: 57000, + }, + { + line_no: 3, + item_code: 'ITM-WLD-E6013', + item_name: 'Welding Rod E6013', + uom: 'BOX', + ordered_qty: 30, + previously_received_qty: 0, + current_qty: 30, + accepted_qty: 28, + rejected_qty: 2, + rate: 985, + line_total: 27580, + }, + { + line_no: 4, + item_code: 'ITM-SFT-GLV', + item_name: 'Safety Gloves (Heat Resistant)', + uom: 'PAIR', + ordered_qty: 200, + previously_received_qty: 0, + current_qty: 200, + accepted_qty: 200, + rejected_qty: 0, + rate: 210, + line_total: 42000, + }, + ], + totals: { + sub_total: 217205, + grand_total: 217205, + }, +}); + +const generateGrnHtml = (inputData = getDummyGrnData()) => { + const data = inputData || getDummyGrnData(); + + const itemsRows = (data.items || []) + .map( + (item, index) => ` + + ${item.line_no || index + 1} + + ${escapeHtml(item.item_name)}
+ ${escapeHtml(item.item_code)} + + ${escapeHtml(item.uom)} + ${Number(item.ordered_qty || 0).toFixed(2)} + ${Number(item.accepted_qty || 0).toFixed(2)} + ${Number(item.rejected_qty || 0).toFixed(2)} + ${formatCurrency(item.rate)} + ${formatCurrency(item.line_total)} + + ` + ) + .join(''); + + const docMetaHtml = ` +
GRN No: ${escapeHtml(data.grn.grn_number)}
+
Date: ${formatDate(data.grn.grn_date)}
+
PO Ref: ${escapeHtml(data.grn.po_number)}
+
${escapeHtml(data.grn.status)}
+ `; + + return ` + + + + + Goods Receipt Note + + + +
+ ${renderCompanyHeader({ company: data.company, title: 'GOODS RECEIPT NOTE', docMetaHtml })} + +
+ ${renderPartyCard( + 'Received At', + ` + ${escapeHtml(data.warehouse.name)} (${escapeHtml(data.warehouse.code)})
+ ${escapeHtml(data.warehouse.address)}
+ ${escapeHtml(data.company.name)} + ` + )} + ${renderPartyCard( + 'Supplier', + ` + ${escapeHtml(data.vendor.vendor_name)}
+ ${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}
+ ${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}
+ GSTIN: ${escapeHtml(data.vendor.gstin)}
+ Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)}) + ` + )} +
+ +
+
+
Invoice & Transport
+
+
Vendor Invoice No${escapeHtml(data.grn.vendor_invoice_no)}
+
Invoice Date${formatDate(data.grn.vendor_invoice_date)}
+
Invoice Amount${formatCurrency(data.grn.vendor_invoice_amount)}
+
Vehicle No${escapeHtml(data.grn.vehicle_no)}
+
LR No / Date${escapeHtml(data.grn.lr_no)} / ${formatDate(data.grn.lr_date)}
+
+
+
+
Receipt Details
+
+
Received By${escapeHtml(data.grn.received_by)}
+
Quality Checked By${escapeHtml(data.grn.quality_checked_by)}
+
Warehouse${escapeHtml(data.warehouse.code)}
+
PO Reference${escapeHtml(data.grn.po_number)}
+
+
+
+ + + + + + + + + + + + + + + ${itemsRows} +
S.NoItemUOMOrderedAcceptedRejectedRateAmount
+ +
+
+ + + + + + + + + +
Accepted Value${formatCurrency(data.totals.sub_total)}
Total Received Value${formatCurrency(data.totals.grand_total)}
+
+
+ +
Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}
+ +
+

Remarks

+
${escapeHtml(data.grn.remarks)}
+
+ + ${renderFootnote()} +
+ + +`; +}; + +module.exports = { generateGrnHtml, getDummyGrnData }; diff --git a/src/utils/pdf/templates/po.template.js b/src/utils/pdf/templates/po.template.js new file mode 100644 index 0000000..64adb18 --- /dev/null +++ b/src/utils/pdf/templates/po.template.js @@ -0,0 +1,424 @@ +const { formatCurrency } = require('../helpers/formatCurrency'); +const { formatDate } = require('../helpers/formatDate'); +const { numberToWords } = require('../helpers/numberToWords'); + +const getDummyPoData = () => ({ + company: { + name: 'Acme Infra & Engineering Pvt. Ltd.', + address: '3rd Floor, Business Tower, Sector 62', + city: 'Noida', + state: 'Uttar Pradesh', + pincode: '201301', + gstin: '09AABCA1234K1Z7', + phone: '+91 120 4400 221', + email: 'procurement@acmeinfra.in', + }, + po: { + po_number: 'PO-2026-00047', + po_date: '2026-06-20', + expected_delivery_date: '2026-06-30', + status: 'Approved', + payment_term: '30 Days from Invoice Date', + delivery_term: 'Door Delivery - Main Plant', + terms_and_conditions: + 'Material must conform to approved specifications. Any variation should be approved in writing before dispatch.', + remarks: 'Please mention PO number in invoice and delivery challan.', + }, + vendor: { + vendor_name: 'Shree Industrial Supplies', + gstin: '27AAECS7788B1Z2', + address: 'Plot 17, MIDC Industrial Area', + city: 'Pune', + state: 'Maharashtra', + pincode: '411019', + contact_name: 'Rajesh Patil', + phone: '+91 98220 12345', + }, + items: [ + { + line_no: 1, + item_name: 'MS Channel 100x50 mm', + uom: 'KG', + ordered_qty: 1250, + rate: 72.5, + gst_rate_pct: 18, + taxable_amount: 90625, + tax_amount: 16312.5, + line_total: 106937.5, + }, + { + line_no: 2, + item_name: 'Industrial Fasteners Set M12', + uom: 'BOX', + ordered_qty: 40, + rate: 1425, + gst_rate_pct: 18, + taxable_amount: 57000, + tax_amount: 10260, + line_total: 67260, + }, + { + line_no: 3, + item_name: 'Welding Rod E6013', + uom: 'BOX', + ordered_qty: 30, + rate: 985, + gst_rate_pct: 12, + taxable_amount: 29550, + tax_amount: 3546, + line_total: 33096, + }, + { + line_no: 4, + item_name: 'Safety Gloves (Heat Resistant)', + uom: 'PAIR', + ordered_qty: 200, + rate: 210, + gst_rate_pct: 5, + taxable_amount: 42000, + tax_amount: 2100, + line_total: 44100, + }, + ], + totals: { + sub_total: 219175, + tax_total: 32218.5, + freight_charges: 2500, + other_charges: 850, + grand_total: 254743.5, + }, +}); + +const escapeHtml = (value = '') => + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const calculateTaxSplit = (taxTotal) => { + const halfTax = Number((Number(taxTotal || 0) / 2).toFixed(2)); + return { cgst: halfTax, sgst: halfTax }; +}; + +const generatePoHtml = (inputData = getDummyPoData()) => { + const data = inputData || getDummyPoData(); + const taxSplit = calculateTaxSplit(data.totals.tax_total); + const itemsRows = (data.items || []) + .map( + (item, index) => ` + + ${item.line_no || index + 1} + ${escapeHtml(item.item_name)} + ${escapeHtml(item.uom)} + ${Number(item.ordered_qty || 0).toFixed(2)} + ${formatCurrency(item.rate)} + ${Number(item.gst_rate_pct || 0).toFixed(2)}% + ${formatCurrency(item.line_total)} + + ` + ) + .join(''); + + return ` + + + + + + Purchase Order + + + +
+
+
+ +
+

${escapeHtml(data.company.name)}

+
+ ${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}, ${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}
+ GSTIN: ${escapeHtml(data.company.gstin)} | Phone: ${escapeHtml(data.company.phone)}
+ Email: ${escapeHtml(data.company.email)} +
+
+
+
+

PURCHASE ORDER

+
+
PO No: ${escapeHtml(data.po.po_number)}
+
Date: ${formatDate(data.po.po_date)}
+
Delivery: ${formatDate(data.po.expected_delivery_date)}
+
${escapeHtml(data.po.status)}
+
+
+
+ +
+
+
From
+
+ ${escapeHtml(data.company.name)}
+ ${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}
+ ${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}
+ GSTIN: ${escapeHtml(data.company.gstin)}
+ Phone: ${escapeHtml(data.company.phone)} +
+
+
+
To
+
+ ${escapeHtml(data.vendor.vendor_name)}
+ ${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}
+ ${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}
+ GSTIN: ${escapeHtml(data.vendor.gstin)}
+ Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)}) +
+
+
+ + + + + + + + + + + + + + + ${itemsRows} + +
S.NoItemUOMQtyRateGST%Amount
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Sub Total${formatCurrency(data.totals.sub_total)}
CGST${formatCurrency(taxSplit.cgst)}
SGST${formatCurrency(taxSplit.sgst)}
Freight${formatCurrency(data.totals.freight_charges)}
Other Charges${formatCurrency(data.totals.other_charges)}
Grand Total${formatCurrency(data.totals.grand_total)}
+
+
+ +
Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}
+ +
+

Terms & Conditions

+
Payment Term: ${escapeHtml(data.po.payment_term)}
+
Delivery Term: ${escapeHtml(data.po.delivery_term)}
+
${escapeHtml(data.po.terms_and_conditions)}
+
Remarks: ${escapeHtml(data.po.remarks)}
+
+ +
+ This is a computer generated document. + +
+
+ + +`; +}; + +module.exports = { generatePoHtml, getDummyPoData };