diff --git a/BACKEND_TASKS.md b/BACKEND_TASKS.md
index 43f4dc6..f5cb37a 100644
--- a/BACKEND_TASKS.md
+++ b/BACKEND_TASKS.md
@@ -309,7 +309,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Status | Method | Endpoint | RBAC | Notes |
|--------|--------|----------|------|-------|
-| [x] | GET | `/settings/company` | view | Company profile (org name, contact, address, logo URL) |
+| [x] | GET | `/settings/company` | view | Company profile (org name, GSTIN, contact, address, logo URL) |
| [x] | PUT | `/settings/company` | edit | Update company profile |
| [x] | POST | `/settings/company/logo` | edit | Upload logo (`multipart/form-data`, field `logo`) |
| [x] | GET | `/settings/email` | view | SMTP settings (`has_smtp_password` flag; password never returned) |
@@ -317,6 +317,8 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
**DB patch:** `scripts/patch-company-email-settings.sql` — creates `company` + `email_settings` singleton tables and `SETTINGS` module permissions for Super Admin.
+**DB patch:** `scripts/patch-company-gstin.sql` — adds `company.gstin` for PDF document headers.
+
---
### Audit Logs (`/audit-logs`) — module: `AUDIT_LOGS`
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1778640..6d6c1bd 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1004,6 +1004,7 @@ model vendors {
model company {
id BigInt @id @default(1)
org_name String? @db.VarChar(200)
+ gstin String? @db.VarChar(15)
mobile String? @db.VarChar(20)
email String? @db.VarChar(200)
website String? @db.VarChar(255)
diff --git a/scripts/patch-company-email-settings.sql b/scripts/patch-company-email-settings.sql
index e0a68af..f31458e 100644
--- a/scripts/patch-company-email-settings.sql
+++ b/scripts/patch-company-email-settings.sql
@@ -3,6 +3,7 @@
CREATE TABLE IF NOT EXISTS company (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
org_name VARCHAR(200),
+ gstin VARCHAR(15),
mobile VARCHAR(20),
email VARCHAR(200),
website VARCHAR(255),
@@ -17,6 +18,9 @@ CREATE TABLE IF NOT EXISTS company (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+ALTER TABLE company
+ ADD COLUMN IF NOT EXISTS gstin VARCHAR(15);
+
CREATE TABLE IF NOT EXISTS email_settings (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
smtp_host VARCHAR(255),
diff --git a/scripts/patch-company-gstin.sql b/scripts/patch-company-gstin.sql
new file mode 100644
index 0000000..07b31fc
--- /dev/null
+++ b/scripts/patch-company-gstin.sql
@@ -0,0 +1,4 @@
+-- Add GSTIN to company profile (used on PO/GRN PDF headers)
+
+ALTER TABLE company
+ ADD COLUMN IF NOT EXISTS gstin VARCHAR(15);
diff --git a/src/docs/settings-routes.yaml b/src/docs/settings-routes.yaml
index c323fad..95d0cde 100644
--- a/src/docs/settings-routes.yaml
+++ b/src/docs/settings-routes.yaml
@@ -8,6 +8,7 @@ components:
minProperties: 1
properties:
org_name: { type: string, example: 'Bharat Consumer Products' }
+ gstin: { type: string, example: '33AABCB1234D1Z5', description: '15-character GSTIN' }
mobile: { type: string, example: '9876543210' }
email: { type: string, format: email, example: 'info@company.com' }
website: { type: string, example: 'https://www.company.com' }
diff --git a/src/modules/grn/grn.service.js b/src/modules/grn/grn.service.js
index 3e634a0..a1cb390 100644
--- a/src/modules/grn/grn.service.js
+++ b/src/modules/grn/grn.service.js
@@ -3,7 +3,10 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode');
-const { buildSimplePdf } = require('../../utils/simplePdf');
+const { generatePdf } = require('../../utils/pdf/pdfGenerator');
+const { generateGrnHtml } = require('../../utils/pdf/templates/grn.template');
+const { formatDate } = require('../../utils/pdf/helpers/formatDate');
+const { getCompanyForDocuments } = require('../settings/settings.service');
const repository = require('./grn.repository');
const { assertWarehouse } = require('../../utils/locations');
const { sanitizeAttachment } = require('./grn.attachments.service');
@@ -39,6 +42,43 @@ const grnDetailInclude = {
},
};
+const grnPdfInclude = {
+ ...grnDetailInclude,
+ vendors: {
+ select: {
+ id: true,
+ vendor_code: true,
+ vendor_name: true,
+ gstin: true,
+ vendor_addresses: {
+ where: { is_active: true },
+ orderBy: { id: 'asc' },
+ take: 5,
+ select: {
+ address_type: true,
+ address_line1: true,
+ address_line2: true,
+ city: true,
+ state: true,
+ pincode: true,
+ gstin: true,
+ },
+ },
+ },
+ },
+ warehouse: {
+ select: {
+ id: true,
+ code: true,
+ name: true,
+ address: true,
+ city: true,
+ state: true,
+ pincode: true,
+ },
+ },
+};
+
const toDateOnly = (value) => {
if (!value) return null;
const date = new Date(value);
@@ -97,15 +137,113 @@ const sanitizeGrn = (row) => {
};
};
-const getGrnOrThrow = async (id, { includeItems = false } = {}) => {
+const getGrnOrThrow = async (id, { includeItems = false, forPdf = false } = {}) => {
+ const include = forPdf ? grnPdfInclude : includeItems ? grnDetailInclude : grnListInclude;
const row = await prisma.grn.findFirst({
where: { id: BigInt(id), deleted_at: null },
- include: includeItems ? grnDetailInclude : grnListInclude,
+ include,
});
if (!row) throw new ApiError(404, 'GRN not found');
return row;
};
+const humanizeLabel = (value) => {
+ if (!value) return '-';
+ return String(value)
+ .toLowerCase()
+ .split('_')
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+};
+
+const pickVendorAddress = (addresses = []) => {
+ if (!addresses.length) return null;
+ return (
+ addresses.find((row) => String(row.address_type || '').toUpperCase() === 'BILLING') ||
+ addresses.find((row) => String(row.address_type || '').toUpperCase() === 'SHIPPING') ||
+ addresses[0]
+ );
+};
+
+const buildVendorAddressLine = (address) => {
+ if (!address) return '';
+ return [address.address_line1, address.address_line2].filter(Boolean).join(', ');
+};
+
+const buildGrnPdfPayload = (grn, company) => {
+ const vendorAddress = pickVendorAddress(grn.vendor?.vendor_addresses || []);
+ const items = (grn.items || []).map((line) => {
+ const acceptedQty = toNum(line.accepted_qty);
+ const rejectedQty = toNum(line.rejected_qty);
+ const rate = toNum(line.rate);
+ return {
+ line_no: line.line_no,
+ item_name: line.item?.item_name || '-',
+ item_code: line.item?.item_code || null,
+ remarks: line.remarks || line.rejection_reason || null,
+ uom: line.uom?.code || line.uom?.name || '-',
+ ordered_qty: toNum(line.ordered_qty),
+ current_qty: toNum(line.current_qty),
+ accepted_qty: acceptedQty,
+ rejected_qty: rejectedQty,
+ rate,
+ line_total: acceptedQty * rate,
+ };
+ });
+
+ const acceptedValue = items.reduce((sum, line) => sum + toNum(line.line_total), 0);
+ const rejectedValue = items.reduce(
+ (sum, line) => sum + toNum(line.rejected_qty) * toNum(line.rate),
+ 0
+ );
+
+ return {
+ company: {
+ ...company,
+ gstin: company.gstin || '',
+ },
+ grn: {
+ grn_number: grn.grn_number,
+ grn_date: grn.grn_date,
+ status: humanizeLabel(grn.status),
+ po_number: grn.purchase_order?.po_number || '-',
+ vendor_invoice_no: grn.vendor_invoice_no || null,
+ vendor_invoice_date: grn.vendor_invoice_date || null,
+ vendor_invoice_amount:
+ grn.vendor_invoice_amount != null ? toNum(grn.vendor_invoice_amount) : null,
+ vehicle_no: grn.vehicle_no || null,
+ lr_no: grn.lr_no || null,
+ lr_date: grn.lr_date || null,
+ received_by: grn.received_by_user?.full_name || '-',
+ quality_checked_by: grn.quality_checked_by_user?.full_name || '-',
+ remarks: grn.remarks || null,
+ },
+ vendor: {
+ vendor_name: grn.vendor?.vendor_name || '-',
+ gstin: grn.vendor?.gstin || vendorAddress?.gstin || '',
+ address: buildVendorAddressLine(vendorAddress),
+ city: vendorAddress?.city || '',
+ state: vendorAddress?.state || '',
+ pincode: vendorAddress?.pincode || '',
+ },
+ warehouse: {
+ name: grn.warehouse?.name || '-',
+ address: grn.warehouse?.address || '',
+ city: grn.warehouse?.city || '',
+ state: grn.warehouse?.state || '',
+ pincode: grn.warehouse?.pincode || '',
+ },
+ items,
+ totals: {
+ accepted_value: acceptedValue,
+ rejected_value: rejectedValue,
+ grand_total: acceptedValue,
+ },
+ generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,
+ };
+};
+
const getReceivablePoOrThrow = async (poId) => {
const po = await prisma.purchase_orders.findFirst({
where: { id: BigInt(poId), deleted_at: null },
@@ -430,25 +568,14 @@ const cancelGrn = async (id, payload, userId, requestId) => {
};
const getGrnPdf = async (id) => {
- const grn = sanitizeGrn(await getGrnOrThrow(id, { includeItems: true }));
- const lines = [
- `GRN: ${grn.grn_number}`,
- `Date: ${grn.grn_date ? new Date(grn.grn_date).toISOString().slice(0, 10) : '-'}`,
- `Status: ${grn.status}`,
- `PO: ${grn.purchase_order?.po_number || '-'}`,
- `Vendor: ${grn.vendor?.vendor_name || '-'}`,
- `Warehouse: ${grn.warehouse?.name || '-'}`,
- '',
- 'Line Items:',
- ...grn.items.map(
- (line) =>
- `${line.line_no}. ${line.item?.item_name || line.item_id} | Accepted ${line.accepted_qty} / Current ${line.current_qty}`
- ),
- ];
+ const grn = sanitizeGrn(await getGrnOrThrow(id, { forPdf: true }));
+ const company = await getCompanyForDocuments();
+ const html = generateGrnHtml(buildGrnPdfPayload(grn, company));
+ const buffer = await generatePdf(html);
return {
filename: `${grn.grn_number.replace(/\//g, '-')}.pdf`,
- buffer: buildSimplePdf(lines),
+ buffer,
};
};
diff --git a/src/modules/purchase-orders/purchase-orders.service.js b/src/modules/purchase-orders/purchase-orders.service.js
index d9d401b..720133a 100644
--- a/src/modules/purchase-orders/purchase-orders.service.js
+++ b/src/modules/purchase-orders/purchase-orders.service.js
@@ -3,7 +3,10 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode');
-const { buildSimplePdf } = require('../../utils/simplePdf');
+const { generatePdf } = require('../../utils/pdf/pdfGenerator');
+const { generatePoHtml } = require('../../utils/pdf/templates/po.template');
+const { formatDate } = require('../../utils/pdf/helpers/formatDate');
+const { getCompanyForDocuments } = require('../settings/settings.service');
const {
EDITABLE_STATUSES,
SUBMITTABLE_STATUSES,
@@ -48,6 +51,43 @@ const poDetailInclude = {
},
};
+const poPdfInclude = {
+ ...poDetailInclude,
+ vendors: {
+ select: {
+ id: true,
+ vendor_code: true,
+ vendor_name: true,
+ gstin: true,
+ vendor_addresses: {
+ where: { is_active: true },
+ orderBy: { id: 'asc' },
+ take: 5,
+ select: {
+ address_type: true,
+ address_line1: true,
+ address_line2: true,
+ city: true,
+ state: true,
+ pincode: true,
+ gstin: true,
+ },
+ },
+ },
+ },
+ plant: {
+ select: {
+ id: true,
+ code: true,
+ name: true,
+ address: true,
+ city: true,
+ state: true,
+ pincode: true,
+ },
+ },
+};
+
const toDateOnly = (value) => {
if (!value) return null;
const date = new Date(value);
@@ -251,15 +291,100 @@ const buildHeaderData = async (payload, builtItems, userId) => {
};
};
-const getPoOrThrow = async (id, { includeItems = false } = {}) => {
+const getPoOrThrow = async (id, { includeItems = false, forPdf = false } = {}) => {
+ const include = forPdf ? poPdfInclude : includeItems ? poDetailInclude : poListInclude;
const po = await prisma.purchase_orders.findFirst({
where: { id: BigInt(id), deleted_at: null },
- include: includeItems ? poDetailInclude : poListInclude,
+ include,
});
if (!po) throw new ApiError(404, 'Purchase order not found');
return po;
};
+const humanizeLabel = (value) => {
+ if (!value) return '-';
+ return String(value)
+ .toLowerCase()
+ .split('_')
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+};
+
+const pickVendorAddress = (addresses = []) => {
+ if (!addresses.length) return null;
+ const preferred =
+ addresses.find((row) => String(row.address_type || '').toUpperCase() === 'BILLING') ||
+ addresses.find((row) => String(row.address_type || '').toUpperCase() === 'SHIPPING') ||
+ addresses[0];
+ return preferred;
+};
+
+const buildVendorAddressLine = (address) => {
+ if (!address) return '';
+ return [address.address_line1, address.address_line2].filter(Boolean).join(', ');
+};
+
+const buildPoPdfPayload = (po, company) => {
+ const vendorAddress = pickVendorAddress(po.vendor?.vendor_addresses || []);
+
+ return {
+ company: {
+ ...company,
+ gstin: company.gstin || '',
+ },
+ po: {
+ po_number: po.po_number,
+ po_date: po.po_date,
+ expected_delivery_date: po.expected_delivery_date,
+ status: humanizeLabel(po.status),
+ po_type: humanizeLabel(po.po_type),
+ revision_no: po.revision_no ?? 0,
+ payment_term: po.payment_term?.name || null,
+ delivery_term: po.delivery_term?.name || null,
+ terms_and_conditions: po.terms_and_conditions || null,
+ remarks: po.remarks || null,
+ },
+ vendor: {
+ vendor_name: po.vendor?.vendor_name || '-',
+ gstin: po.vendor?.gstin || vendorAddress?.gstin || '',
+ address: buildVendorAddressLine(vendorAddress),
+ city: vendorAddress?.city || '',
+ state: vendorAddress?.state || '',
+ pincode: vendorAddress?.pincode || '',
+ },
+ ship_to: {
+ name: po.plant?.name || '-',
+ address: po.plant?.address || '',
+ city: po.plant?.city || '',
+ state: po.plant?.state || '',
+ pincode: po.plant?.pincode || '',
+ },
+ brand: { name: po.brand?.name || '-' },
+ warehouse: { name: po.warehouse?.name || '-' },
+ items: (po.items || []).map((line) => ({
+ line_no: line.line_no,
+ item_name: line.item?.item_name || '-',
+ remarks: line.remarks || null,
+ uom: line.uom?.code || line.uom?.name || '-',
+ ordered_qty: toNum(line.ordered_qty),
+ rate: toNum(line.rate),
+ discount_pct: toNum(line.discount_pct),
+ gst_rate_pct: toNum(line.gst_rate?.rate_pct),
+ line_total: toNum(line.line_total),
+ })),
+ totals: {
+ sub_total: toNum(po.sub_total),
+ tax_total: toNum(po.tax_total),
+ freight_charges: toNum(po.freight_charges),
+ other_charges: toNum(po.other_charges),
+ discount_amount: toNum(po.discount_amount),
+ grand_total: toNum(po.grand_total),
+ },
+ generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,
+ };
+};
+
const hasReceipts = (po) =>
(po.purchase_order_items || []).some((line) => Number(line.received_qty) > 0);
@@ -674,25 +799,14 @@ const amendPurchaseOrder = async (id, payload, userId, requestId) => {
};
const getPurchaseOrderPdf = async (id) => {
- const po = sanitizePo(await getPoOrThrow(id, { includeItems: true }));
- const lines = [
- `Purchase Order: ${po.po_number}`,
- `Date: ${po.po_date ? new Date(po.po_date).toISOString().slice(0, 10) : '-'}`,
- `Status: ${po.status}`,
- `Vendor: ${po.vendor?.vendor_name || '-'}`,
- `Plant: ${po.plant?.name || '-'}`,
- `Grand Total: ${po.grand_total}`,
- '',
- 'Line Items:',
- ...po.items.map(
- (line) =>
- `${line.line_no}. ${line.item?.item_name || line.item_id} | Qty ${line.ordered_qty} @ ${line.rate} = ${line.line_total}`
- ),
- ];
+ const po = sanitizePo(await getPoOrThrow(id, { forPdf: true }));
+ const company = await getCompanyForDocuments();
+ const html = generatePoHtml(buildPoPdfPayload(po, company));
+ const buffer = await generatePdf(html);
return {
filename: `${po.po_number.replace(/\//g, '-')}.pdf`,
- buffer: buildSimplePdf(lines),
+ buffer,
};
};
diff --git a/src/modules/settings/settings.service.js b/src/modules/settings/settings.service.js
index 6bbc86d..029675d 100644
--- a/src/modules/settings/settings.service.js
+++ b/src/modules/settings/settings.service.js
@@ -20,6 +20,7 @@ const sanitizeCompany = (row) => {
return {
id: row.id,
org_name: row.org_name,
+ gstin: row.gstin,
mobile: row.mobile,
email: row.email,
website: row.website,
@@ -72,6 +73,7 @@ const updateCompany = async (payload, userId, requestId) => {
const data = {
...(payload.org_name !== undefined ? { org_name: payload.org_name || null } : {}),
+ ...(payload.gstin !== undefined ? { gstin: payload.gstin || null } : {}),
...(payload.mobile !== undefined ? { mobile: payload.mobile || null } : {}),
...(payload.email !== undefined ? { email: payload.email || null } : {}),
...(payload.website !== undefined ? { website: payload.website || null } : {}),
@@ -179,7 +181,7 @@ const getCompanyForDocuments = async () => {
city: row.city || '',
state: row.state || '',
pincode: row.pincode || '',
- gstin: '',
+ gstin: row.gstin || '',
phone: row.mobile || '',
email: row.email || '',
website: row.website || '',
diff --git a/src/modules/settings/settings.validation.js b/src/modules/settings/settings.validation.js
index 7eeef15..cccc6fd 100644
--- a/src/modules/settings/settings.validation.js
+++ b/src/modules/settings/settings.validation.js
@@ -3,6 +3,7 @@ const { masterName } = require('../masters/_shared/masters.validation');
const companyUpdateSchema = Joi.object({
org_name: masterName({ max: 200 }),
+ gstin: Joi.string().length(15).allow(null, '').optional(),
mobile: Joi.string().max(20).allow(null, '').optional(),
email: Joi.string().email().max(200).allow(null, '').optional(),
website: Joi.string().max(255).allow(null, '').optional(),
diff --git a/src/utils/pdf/helpers/formatDate.js b/src/utils/pdf/helpers/formatDate.js
index e04a20d..a62bf0e 100644
--- a/src/utils/pdf/helpers/formatDate.js
+++ b/src/utils/pdf/helpers/formatDate.js
@@ -1,16 +1,45 @@
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
-const formatDate = (value) => {
- if (!value) return '-';
+const pad2 = (value) => String(value).padStart(2, '0');
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return '-';
+const toValidDate = (value) => {
+ if (!value) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return null;
+ return date;
+};
- const day = String(date.getDate()).padStart(2, '0');
- const month = MONTHS[date.getMonth()];
- const year = date.getFullYear();
+const isDateOnlyString = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
- return `${day}-${month}-${year}`;
+/**
+ * Prefer UTC for calendar dates (Prisma `@db.Date` / YYYY-MM-DD) so timezone
+ * shifts do not move the day. Use local time for datetime display.
+ * @param {Date|string|number|null|undefined} value
+ * @param {{ style?: 'short' | 'numeric' | 'datetime' }} [options]
+ * @returns {string}
+ */
+const formatDate = (value, options = {}) => {
+ const date = toValidDate(value);
+ if (!date) return '-';
+
+ const style = options.style || 'short';
+ const useUtc = style !== 'datetime' && (isDateOnlyString(value) || style === 'numeric' || style === 'short');
+
+ const day = pad2(useUtc ? date.getUTCDate() : date.getDate());
+ const monthIdx = useUtc ? date.getUTCMonth() : date.getMonth();
+ const year = useUtc ? date.getUTCFullYear() : date.getFullYear();
+
+ if (style === 'numeric') {
+ return `${day}/${pad2(monthIdx + 1)}/${year}`;
+ }
+
+ if (style === 'datetime') {
+ const hours = pad2(date.getHours());
+ const minutes = pad2(date.getMinutes());
+ return `${day}/${pad2(monthIdx + 1)}/${year} ${hours}:${minutes}`;
+ }
+
+ return `${day}-${MONTHS[monthIdx]}-${year}`;
};
module.exports = { formatDate };
diff --git a/src/utils/pdf/helpers/numberToWords.js b/src/utils/pdf/helpers/numberToWords.js
index b87cb7c..ce9f2f3 100644
--- a/src/utils/pdf/helpers/numberToWords.js
+++ b/src/utils/pdf/helpers/numberToWords.js
@@ -67,10 +67,10 @@ const numberToWords = (value = 0) => {
const paiseWords = decimalPart ? twoDigitsToWords(decimalPart) : '';
if (decimalPart) {
- return `${rupeesWords} Rupees and ${paiseWords} Paise Only`;
+ return `Rupees ${rupeesWords} and ${paiseWords} Paise Only`;
}
- return `${rupeesWords} Rupees Only`;
+ return `Rupees ${rupeesWords} Only`;
};
module.exports = { numberToWords };
diff --git a/src/utils/pdf/pdfGenerator.js b/src/utils/pdf/pdfGenerator.js
index 4fae3e8..f452a2b 100644
--- a/src/utils/pdf/pdfGenerator.js
+++ b/src/utils/pdf/pdfGenerator.js
@@ -1,8 +1,23 @@
+const fs = require('fs');
+
+const CANDIDATE_CHROME_PATHS = [
+ process.env.PUPPETEER_EXECUTABLE_PATH,
+ '/usr/bin/google-chrome',
+ '/usr/bin/google-chrome-stable',
+ '/usr/bin/chromium-browser',
+ '/usr/bin/chromium',
+].filter(Boolean);
+
+const resolveChromePath = () => CANDIDATE_CHROME_PATHS.find((path) => fs.existsSync(path));
+
const generatePdf = async (htmlContent) => {
const puppeteer = await import('puppeteer');
+ const executablePath = resolveChromePath();
+
const browser = await puppeteer.default.launch({
headless: true,
- args: ['--no-sandbox', '--disable-setuid-sandbox'],
+ ...(executablePath ? { executablePath } : {}),
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
try {
@@ -12,10 +27,10 @@ const generatePdf = async (htmlContent) => {
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
- margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' },
+ margin: { top: '14mm', right: '14mm', bottom: '14mm', left: '14mm' },
});
- return pdfBuffer;
+ return Buffer.from(pdfBuffer);
} finally {
await browser.close();
}
diff --git a/src/utils/pdf/templates/grn.template.js b/src/utils/pdf/templates/grn.template.js
index 461fcd0..743782a 100644
--- a/src/utils/pdf/templates/grn.template.js
+++ b/src/utils/pdf/templates/grn.template.js
@@ -1,231 +1,531 @@
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(),
+ company: {
+ name: 'Bharat Industries Pvt. Ltd.',
+ address: 'Plot 14, Guindy Industrial Estate',
+ city: 'Chennai',
+ state: 'Tamil Nadu',
+ pincode: '600032',
+ gstin: '33AABCB1234D1Z5',
+ phone: '+91 44 4000 1200',
+ email: 'procurement@bharatindustries.in',
+ },
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',
+ grn_number: 'GRN/2026-27/00012',
+ grn_date: '2026-07-14',
+ status: 'Posted',
+ po_number: 'PO/2026-27/00007',
+ vendor_invoice_no: 'TSL/INV/2026/441',
+ vendor_invoice_date: '2026-07-13',
+ vendor_invoice_amount: 139943.75,
+ vehicle_no: 'TN-09-AB-4521',
lr_no: 'LR-77821',
- lr_date: '2026-06-21',
+ lr_date: '2026-07-13',
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',
+ vendor_name: 'Tata Steel Limited',
+ gstin: '20AABCT3456A1Z9',
+ address: 'Bistupur Main Road',
+ city: 'Jamshedpur',
+ state: 'Jharkhand',
+ pincode: '831001',
},
warehouse: {
- code: 'WH-NOI-01',
- name: 'Noida Main Warehouse',
- address: 'Plot 9, Industrial Estate, Sector 63, Noida',
+ name: 'Main warehouse',
+ address: 'Guindy Industrial Estate',
+ city: 'Chennai',
+ state: 'Tamil Nadu',
+ pincode: '600032',
},
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,
+ item_name: 'TMT steel bars 12mm',
+ item_code: 'ITM-TMT-12',
+ uom: 'MT',
+ ordered_qty: 25,
+ current_qty: 25,
+ accepted_qty: 25,
rejected_qty: 0,
- rate: 72.5,
- line_total: 90625,
+ rate: 4250,
+ line_total: 106250,
},
{
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,
+ item_name: 'Cement OPC 53 grade',
+ item_code: 'ITM-CEM-53',
+ uom: 'Bag',
+ ordered_qty: 500,
+ current_qty: 500,
+ accepted_qty: 500,
rejected_qty: 0,
- rate: 1425,
- line_total: 57000,
+ rate: 380,
+ line_total: 190000,
},
{
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,
+ item_name: 'River sand',
+ item_code: 'ITM-SND-01',
+ remarks: '2 bags damaged in transit',
+ uom: 'CFT',
+ ordered_qty: 1200,
+ current_qty: 1200,
+ accepted_qty: 1180,
+ rejected_qty: 20,
+ rate: 45,
+ line_total: 53100,
},
],
totals: {
- sub_total: 217205,
- grand_total: 217205,
+ accepted_value: 349350,
+ rejected_value: 900,
+ grand_total: 349350,
},
+ generated_at: '14/07/2026 09:40',
});
+const escapeHtml = (value = '') =>
+ String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+const joinParts = (...parts) => parts.map((p) => String(p || '').trim()).filter(Boolean).join(', ');
+
+const companyAddressLine = (company = {}) =>
+ joinParts(company.address, company.city, company.state, company.pincode);
+
+const companyContactLine = (company = {}) => {
+ const parts = [];
+ if (company.gstin) parts.push(`GSTIN: ${company.gstin}`);
+ if (company.email) parts.push(company.email);
+ if (company.phone) parts.push(company.phone);
+ return parts.join(' · ');
+};
+
+const qtyDisplay = (value) => {
+ const num = Number(value || 0);
+ return Number.isInteger(num) ? String(num) : num.toFixed(2);
+};
+
const generateGrnHtml = (inputData = getDummyGrnData()) => {
const data = inputData || getDummyGrnData();
+ const company = data.company || {};
+ const grn = data.grn || {};
+ const vendor = data.vendor || {};
+ const warehouse = data.warehouse || {};
+ const totals = data.totals || {};
const itemsRows = (data.items || [])
- .map(
- (item, index) => `
+ .map((item, index) => {
+ const code = item.item_code
+ ? `
${escapeHtml(item.item_code)}
`
+ : '';
+ const remarks = item.remarks
+ ? `*${escapeHtml(item.remarks)}
`
+ : '';
+ return `
- | ${item.line_no || index + 1} |
-
- ${escapeHtml(item.item_name)}
- ${escapeHtml(item.item_code)}
+ | ${item.line_no || index + 1} |
+
+ ${escapeHtml(item.item_name || '-')}
+ ${code}
+ ${remarks}
|
- ${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)} |
+ ${qtyDisplay(item.ordered_qty)} |
+ ${qtyDisplay(item.current_qty)} |
+ ${qtyDisplay(item.accepted_qty)} |
+ ${qtyDisplay(item.rejected_qty)} |
+ ${escapeHtml(item.uom || '-')} |
+ ${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)}
- `;
+ const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode);
+ const warehouseAddress = joinParts(
+ warehouse.address,
+ warehouse.city,
+ warehouse.state,
+ warehouse.pincode
+ );
+ const generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' });
+ const lrLine = [grn.lr_no, grn.lr_date ? formatDate(grn.lr_date, { style: 'numeric' }) : null]
+ .filter(Boolean)
+ .join(' / ');
return `
- Goods Receipt Note
-
+ Goods Receipt Note ${escapeHtml(grn.grn_number || '')}
+
- ${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)}
+