GWM : error message handling

This commit is contained in:
Gowtham M 2026-06-23 15:41:43 +05:30
parent f04b917b76
commit fe7b35a730
15 changed files with 1806 additions and 36 deletions

209
package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -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",

View File

@ -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);
});

View File

@ -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);
};

View File

@ -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 };

113
src/utils/mapDbError.js Normal file
View File

@ -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,
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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 };

View File

@ -0,0 +1,258 @@
const escapeHtml = (value = '') =>
String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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 `
<div class="header">
<div class="brand">
<div class="logo">${escapeHtml(initials)}</div>
<div class="company">
<h1>${escapeHtml(company.name)}</h1>
<div class="meta">
${escapeHtml(company.address)}, ${escapeHtml(company.city)}, ${escapeHtml(company.state)} - ${escapeHtml(company.pincode)}<br />
GSTIN: ${escapeHtml(company.gstin)} | Phone: ${escapeHtml(company.phone)}<br />
Email: ${escapeHtml(company.email)}
</div>
</div>
</div>
<div class="title-block">
<h2 class="title">${escapeHtml(title)}</h2>
<div class="doc-meta">${docMetaHtml}</div>
</div>
</div>
`;
};
const renderPartyCard = (title, bodyHtml) => `
<div class="card">
<div class="card-title">${escapeHtml(title)}</div>
<div class="card-body">${bodyHtml}</div>
</div>
`;
const renderFootnote = () => `
<div class="footnote">
<span>This is a computer generated document.</span>
<span class="page-number"></span>
</div>
`;
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,
};

View File

@ -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 };

View File

@ -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) => `
<tr>
<td>${index + 1}</td>
<td>${formatDate(visit.visit_date)}</td>
<td>${escapeHtml(visit.visit_type)}</td>
<td>${escapeHtml(visit.status)}</td>
<td>${escapeHtml(visit.engineer_name)}</td>
<td>${escapeHtml(visit.work_done)}</td>
</tr>
`
)
.join('');
const docMetaHtml = `
<div><strong>Contract No:</strong> ${escapeHtml(data.contract.contract_no)}</div>
<div><strong>Valid From:</strong> ${formatDate(data.contract.start_date)}</div>
<div><strong>Valid To:</strong> ${formatDate(data.contract.end_date)}</div>
<div style="margin-top: 4px;"><span class="status">${data.contract.is_active ? 'ACTIVE' : 'INACTIVE'}</span></div>
`;
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AMC Contract Summary</title>
<style>${getBaseStyles()}</style>
</head>
<body>
<div class="document">
${renderCompanyHeader({ company: data.company, title: 'AMC CONTRACT SUMMARY', docMetaHtml })}
<div class="party-grid">
${renderPartyCard(
'Asset Owner',
`
<strong>${escapeHtml(data.company.name)}</strong><br />
Plant: ${escapeHtml(data.asset.plant_name)}<br />
Department: ${escapeHtml(data.asset.department_name)}
`
)}
${renderPartyCard(
'AMC Vendor',
`
<strong>${escapeHtml(data.vendor.vendor_name)}</strong> (${escapeHtml(data.vendor.vendor_code)})<br />
${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}<br />
${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}<br />
GSTIN: ${escapeHtml(data.vendor.gstin)}<br />
Contact: ${escapeHtml(data.contract.contact_person)} (${escapeHtml(data.contract.contact_phone)})
`
)}
</div>
<div class="card" style="margin-top:12px;">
<div class="card-title">Covered Asset</div>
<div class="card-body">
<strong>${escapeHtml(data.asset.asset_name)}</strong> (${escapeHtml(data.asset.asset_code)})<br />
${escapeHtml(data.asset.brand_model)} | S/N: ${escapeHtml(data.asset.serial_number)}
</div>
</div>
<div class="info-grid">
<div class="card">
<div class="card-title">Contract Terms</div>
<div class="card-body">
<div class="info-row"><span class="info-label">Contract Type</span><span class="info-value">${escapeHtml(data.contract.contract_type)}</span></div>
<div class="info-row"><span class="info-label">Payment Frequency</span><span class="info-value">${escapeHtml(data.contract.payment_frequency)}</span></div>
<div class="info-row"><span class="info-label">Service Frequency</span><span class="info-value">${escapeHtml(data.contract.service_frequency)}</span></div>
<div class="info-row"><span class="info-label">Visits Per Year</span><span class="info-value">${escapeHtml(data.contract.visits_per_year)}</span></div>
<div class="info-row"><span class="info-label">Renewal Date</span><span class="info-value">${formatDate(data.contract.renewal_date)}</span></div>
<div class="info-row"><span class="info-label">Annual Cost</span><span class="info-value">${formatCurrency(data.contract.annual_cost)}</span></div>
</div>
</div>
<div class="card">
<div class="card-title">Vendor Contact</div>
<div class="card-body">
<div class="info-row"><span class="info-label">Contact Person</span><span class="info-value">${escapeHtml(data.contract.contact_person)}</span></div>
<div class="info-row"><span class="info-label">Phone</span><span class="info-value">${escapeHtml(data.contract.contact_phone)}</span></div>
<div class="info-row"><span class="info-label">Email</span><span class="info-value">${escapeHtml(data.contract.contact_email)}</span></div>
<div class="info-row"><span class="info-label">Contract Period</span><span class="info-value">${formatDate(data.contract.start_date)} to ${formatDate(data.contract.end_date)}</span></div>
</div>
</div>
</div>
<div class="amount-words">Annual Cost in Words: ${escapeHtml(numberToWords(data.contract.annual_cost))}</div>
<div class="terms">
<h3>Scope of Work</h3>
<div>${escapeHtml(data.contract.scope_of_work)}</div>
<h3 style="margin-top:10px;">Exclusions</h3>
<div>${escapeHtml(data.contract.exclusions)}</div>
<h3 style="margin-top:10px;">Remarks</h3>
<div>${escapeHtml(data.contract.remarks)}</div>
</div>
<table class="items">
<thead>
<tr>
<th style="width:5%;">S.No</th>
<th style="width:12%;">Visit Date</th>
<th style="width:14%;">Type</th>
<th style="width:12%;">Status</th>
<th style="width:16%;">Engineer</th>
<th>Work Done</th>
</tr>
</thead>
<tbody>${visitRows}</tbody>
</table>
${renderFootnote()}
</div>
</body>
</html>
`;
};
module.exports = { generateAmcHtml, getDummyAmcData };

View File

@ -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]) => `
<div class="info-row">
<span class="info-label">${escapeHtml(label)}</span>
<span class="info-value">${value}</span>
</div>
`
)
.join('');
const generateAssetHtml = (inputData = getDummyAssetData()) => {
const data = inputData || getDummyAssetData();
const docMetaHtml = `
<div><strong>Asset Code:</strong> ${escapeHtml(data.asset.asset_code)}</div>
<div><strong>Registered:</strong> ${formatDate(data.asset.purchase_date)}</div>
<div><strong>Category:</strong> ${escapeHtml(data.category.name)}</div>
<div style="margin-top: 4px;"><span class="status">${escapeHtml(data.asset.status)}</span></div>
`;
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Asset Detail Sheet</title>
<style>${getBaseStyles()}</style>
</head>
<body>
<div class="document">
${renderCompanyHeader({ company: data.company, title: 'ASSET DETAIL SHEET', docMetaHtml })}
<div class="card" style="margin-top:14px;">
<div class="card-title">Asset Identification</div>
<div class="card-body">
<strong style="font-size:14px;">${escapeHtml(data.asset.asset_name)}</strong>
<div style="margin-top:6px;color:#374151;">
${escapeHtml(data.asset.brand_model)} | S/N: ${escapeHtml(data.asset.serial_number)}
</div>
</div>
</div>
<div class="info-grid">
<div class="card">
<div class="card-title">Asset Details</div>
<div class="card-body">
${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)],
])}
</div>
</div>
<div class="card">
<div class="card-title">Location & Assignment</div>
<div class="card-body">
${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)})`,
],
])}
</div>
</div>
</div>
<div class="info-grid">
<div class="card">
<div class="card-title">Financial & Depreciation</div>
<div class="card-body">
${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)],
])}
</div>
</div>
<div class="card">
<div class="card-title">Procurement Reference</div>
<div class="card-body">
${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)],
])}
</div>
</div>
</div>
<div class="terms">
<h3>Remarks</h3>
<div>${escapeHtml(data.asset.remarks)}</div>
</div>
${renderFootnote()}
</div>
</body>
</html>
`;
};
module.exports = { generateAssetHtml, getDummyAssetData };

View File

@ -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) => `
<tr>
<td>${item.line_no || index + 1}</td>
<td class="item-name">
${escapeHtml(item.item_name)}<br />
<span style="color:#6b7280;font-size:10px;">${escapeHtml(item.item_code)}</span>
</td>
<td>${escapeHtml(item.uom)}</td>
<td class="text-right">${Number(item.ordered_qty || 0).toFixed(2)}</td>
<td class="text-right">${Number(item.accepted_qty || 0).toFixed(2)}</td>
<td class="text-right">${Number(item.rejected_qty || 0).toFixed(2)}</td>
<td class="text-right">${formatCurrency(item.rate)}</td>
<td class="text-right">${formatCurrency(item.line_total)}</td>
</tr>
`
)
.join('');
const docMetaHtml = `
<div><strong>GRN No:</strong> ${escapeHtml(data.grn.grn_number)}</div>
<div><strong>Date:</strong> ${formatDate(data.grn.grn_date)}</div>
<div><strong>PO Ref:</strong> ${escapeHtml(data.grn.po_number)}</div>
<div style="margin-top: 4px;"><span class="status">${escapeHtml(data.grn.status)}</span></div>
`;
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Goods Receipt Note</title>
<style>${getBaseStyles()}</style>
</head>
<body>
<div class="document">
${renderCompanyHeader({ company: data.company, title: 'GOODS RECEIPT NOTE', docMetaHtml })}
<div class="party-grid">
${renderPartyCard(
'Received At',
`
<strong>${escapeHtml(data.warehouse.name)}</strong> (${escapeHtml(data.warehouse.code)})<br />
${escapeHtml(data.warehouse.address)}<br />
<strong>${escapeHtml(data.company.name)}</strong>
`
)}
${renderPartyCard(
'Supplier',
`
<strong>${escapeHtml(data.vendor.vendor_name)}</strong><br />
${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}<br />
${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}<br />
GSTIN: ${escapeHtml(data.vendor.gstin)}<br />
Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)})
`
)}
</div>
<div class="info-grid">
<div class="card">
<div class="card-title">Invoice & Transport</div>
<div class="card-body">
<div class="info-row"><span class="info-label">Vendor Invoice No</span><span class="info-value">${escapeHtml(data.grn.vendor_invoice_no)}</span></div>
<div class="info-row"><span class="info-label">Invoice Date</span><span class="info-value">${formatDate(data.grn.vendor_invoice_date)}</span></div>
<div class="info-row"><span class="info-label">Invoice Amount</span><span class="info-value">${formatCurrency(data.grn.vendor_invoice_amount)}</span></div>
<div class="info-row"><span class="info-label">Vehicle No</span><span class="info-value">${escapeHtml(data.grn.vehicle_no)}</span></div>
<div class="info-row"><span class="info-label">LR No / Date</span><span class="info-value">${escapeHtml(data.grn.lr_no)} / ${formatDate(data.grn.lr_date)}</span></div>
</div>
</div>
<div class="card">
<div class="card-title">Receipt Details</div>
<div class="card-body">
<div class="info-row"><span class="info-label">Received By</span><span class="info-value">${escapeHtml(data.grn.received_by)}</span></div>
<div class="info-row"><span class="info-label">Quality Checked By</span><span class="info-value">${escapeHtml(data.grn.quality_checked_by)}</span></div>
<div class="info-row"><span class="info-label">Warehouse</span><span class="info-value">${escapeHtml(data.warehouse.code)}</span></div>
<div class="info-row"><span class="info-label">PO Reference</span><span class="info-value">${escapeHtml(data.grn.po_number)}</span></div>
</div>
</div>
</div>
<table class="items">
<thead>
<tr>
<th style="width:5%;">S.No</th>
<th>Item</th>
<th style="width:8%;">UOM</th>
<th style="width:10%;" class="text-right">Ordered</th>
<th style="width:10%;" class="text-right">Accepted</th>
<th style="width:9%;" class="text-right">Rejected</th>
<th style="width:12%;" class="text-right">Rate</th>
<th style="width:14%;" class="text-right">Amount</th>
</tr>
</thead>
<tbody>${itemsRows}</tbody>
</table>
<div class="summary-wrap">
<div class="summary-box">
<table>
<tr>
<td>Accepted Value</td>
<td class="text-right">${formatCurrency(data.totals.sub_total)}</td>
</tr>
<tr>
<td class="grand-total">Total Received Value</td>
<td class="text-right grand-total">${formatCurrency(data.totals.grand_total)}</td>
</tr>
</table>
</div>
</div>
<div class="amount-words">Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}</div>
<div class="terms">
<h3>Remarks</h3>
<div>${escapeHtml(data.grn.remarks)}</div>
</div>
${renderFootnote()}
</div>
</body>
</html>
`;
};
module.exports = { generateGrnHtml, getDummyGrnData };

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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) => `
<tr>
<td>${item.line_no || index + 1}</td>
<td class="item-name">${escapeHtml(item.item_name)}</td>
<td>${escapeHtml(item.uom)}</td>
<td class="text-right">${Number(item.ordered_qty || 0).toFixed(2)}</td>
<td class="text-right">${formatCurrency(item.rate)}</td>
<td class="text-right">${Number(item.gst_rate_pct || 0).toFixed(2)}%</td>
<td class="text-right">${formatCurrency(item.line_total)}</td>
</tr>
`
)
.join('');
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Purchase Order</title>
<style>
* { 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%;
}
.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);
}
</style>
</head>
<body>
<div class="document">
<div class="header">
<div class="brand">
<div class="logo">AI</div>
<div class="company">
<h1>${escapeHtml(data.company.name)}</h1>
<div class="meta">
${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}, ${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}<br />
GSTIN: ${escapeHtml(data.company.gstin)} | Phone: ${escapeHtml(data.company.phone)}<br />
Email: ${escapeHtml(data.company.email)}
</div>
</div>
</div>
<div class="title-block">
<h2 class="title">PURCHASE ORDER</h2>
<div class="doc-meta">
<div><strong>PO No:</strong> ${escapeHtml(data.po.po_number)}</div>
<div><strong>Date:</strong> ${formatDate(data.po.po_date)}</div>
<div><strong>Delivery:</strong> ${formatDate(data.po.expected_delivery_date)}</div>
<div style="margin-top: 4px;"><span class="status">${escapeHtml(data.po.status)}</span></div>
</div>
</div>
</div>
<div class="party-grid">
<div class="card">
<div class="card-title">From</div>
<div class="card-body">
<strong>${escapeHtml(data.company.name)}</strong><br />
${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}<br />
${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}<br />
GSTIN: ${escapeHtml(data.company.gstin)}<br />
Phone: ${escapeHtml(data.company.phone)}
</div>
</div>
<div class="card">
<div class="card-title">To</div>
<div class="card-body">
<strong>${escapeHtml(data.vendor.vendor_name)}</strong><br />
${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}<br />
${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}<br />
GSTIN: ${escapeHtml(data.vendor.gstin)}<br />
Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)})
</div>
</div>
</div>
<table class="items">
<thead>
<tr>
<th style="width: 6%;">S.No</th>
<th>Item</th>
<th style="width: 9%;">UOM</th>
<th style="width: 11%;" class="text-right">Qty</th>
<th style="width: 14%;" class="text-right">Rate</th>
<th style="width: 10%;" class="text-right">GST%</th>
<th style="width: 16%;" class="text-right">Amount</th>
</tr>
</thead>
<tbody>
${itemsRows}
</tbody>
</table>
<div class="summary-wrap">
<div class="summary-box">
<table>
<tr>
<td>Sub Total</td>
<td class="text-right">${formatCurrency(data.totals.sub_total)}</td>
</tr>
<tr>
<td>CGST</td>
<td class="text-right">${formatCurrency(taxSplit.cgst)}</td>
</tr>
<tr>
<td>SGST</td>
<td class="text-right">${formatCurrency(taxSplit.sgst)}</td>
</tr>
<tr>
<td>Freight</td>
<td class="text-right">${formatCurrency(data.totals.freight_charges)}</td>
</tr>
<tr>
<td>Other Charges</td>
<td class="text-right">${formatCurrency(data.totals.other_charges)}</td>
</tr>
<tr>
<td class="grand-total">Grand Total</td>
<td class="text-right grand-total">${formatCurrency(data.totals.grand_total)}</td>
</tr>
</table>
</div>
</div>
<div class="amount-words">Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}</div>
<div class="terms">
<h3>Terms & Conditions</h3>
<div><strong>Payment Term:</strong> ${escapeHtml(data.po.payment_term)}</div>
<div><strong>Delivery Term:</strong> ${escapeHtml(data.po.delivery_term)}</div>
<div style="margin-top: 6px;">${escapeHtml(data.po.terms_and_conditions)}</div>
<div style="margin-top: 6px;"><strong>Remarks:</strong> ${escapeHtml(data.po.remarks)}</div>
</div>
<div class="footnote">
<span>This is a computer generated document.</span>
<span class="page-number"></span>
</div>
</div>
</body>
</html>
`;
};
module.exports = { generatePoHtml, getDummyPoData };