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 `
+
+ `;
+};
+
+const renderPartyCard = (title, bodyHtml) => `
+
+
${escapeHtml(title)}
+
${bodyHtml}
+
+`;
+
+const renderFootnote = () => `
+
+`;
+
+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)}
+
+
+
+
+
+ | S.No |
+ Visit Date |
+ Type |
+ Status |
+ Engineer |
+ Work Done |
+
+
+ ${visitRows}
+
+
+ ${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)}
+
+
+
+
+
+
+
+ | S.No |
+ Item |
+ UOM |
+ Ordered |
+ Accepted |
+ Rejected |
+ Rate |
+ Amount |
+
+
+ ${itemsRows}
+
+
+
+
+
+
+ | 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
+
+
+
+
+
+
+
+
+
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)})
+
+
+
+
+
+
+
+ | S.No |
+ Item |
+ UOM |
+ Qty |
+ Rate |
+ GST% |
+ Amount |
+
+
+
+ ${itemsRows}
+
+
+
+
+
+
+
+ | 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)}
+
+
+
+
+
+
+`;
+};
+
+module.exports = { generatePoHtml, getDummyPoData };