GWM : pdf desigh for pd and grn

This commit is contained in:
Gowtham M 2026-07-14 09:48:59 +05:30
parent 82676316c6
commit 969647333f
14 changed files with 1234 additions and 507 deletions

View File

@ -309,7 +309,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Status | Method | Endpoint | RBAC | Notes | | 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] | PUT | `/settings/company` | edit | Update company profile |
| [x] | POST | `/settings/company/logo` | edit | Upload logo (`multipart/form-data`, field `logo`) | | [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) | | [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-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` ### Audit Logs (`/audit-logs`) — module: `AUDIT_LOGS`

View File

@ -1004,6 +1004,7 @@ model vendors {
model company { model company {
id BigInt @id @default(1) id BigInt @id @default(1)
org_name String? @db.VarChar(200) org_name String? @db.VarChar(200)
gstin String? @db.VarChar(15)
mobile String? @db.VarChar(20) mobile String? @db.VarChar(20)
email String? @db.VarChar(200) email String? @db.VarChar(200)
website String? @db.VarChar(255) website String? @db.VarChar(255)

View File

@ -3,6 +3,7 @@
CREATE TABLE IF NOT EXISTS company ( CREATE TABLE IF NOT EXISTS company (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
org_name VARCHAR(200), org_name VARCHAR(200),
gstin VARCHAR(15),
mobile VARCHAR(20), mobile VARCHAR(20),
email VARCHAR(200), email VARCHAR(200),
website VARCHAR(255), website VARCHAR(255),
@ -17,6 +18,9 @@ CREATE TABLE IF NOT EXISTS company (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 ( CREATE TABLE IF NOT EXISTS email_settings (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
smtp_host VARCHAR(255), smtp_host VARCHAR(255),

View File

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

View File

@ -8,6 +8,7 @@ components:
minProperties: 1 minProperties: 1
properties: properties:
org_name: { type: string, example: 'Bharat Consumer Products' } org_name: { type: string, example: 'Bharat Consumer Products' }
gstin: { type: string, example: '33AABCB1234D1Z5', description: '15-character GSTIN' }
mobile: { type: string, example: '9876543210' } mobile: { type: string, example: '9876543210' }
email: { type: string, format: email, example: 'info@company.com' } email: { type: string, format: email, example: 'info@company.com' }
website: { type: string, example: 'https://www.company.com' } website: { type: string, example: 'https://www.company.com' }

View File

@ -3,7 +3,10 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog'); const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination'); const { getPagination } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode'); 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 repository = require('./grn.repository');
const { assertWarehouse } = require('../../utils/locations'); const { assertWarehouse } = require('../../utils/locations');
const { sanitizeAttachment } = require('./grn.attachments.service'); 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) => { const toDateOnly = (value) => {
if (!value) return null; if (!value) return null;
const date = new Date(value); 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({ const row = await prisma.grn.findFirst({
where: { id: BigInt(id), deleted_at: null }, where: { id: BigInt(id), deleted_at: null },
include: includeItems ? grnDetailInclude : grnListInclude, include,
}); });
if (!row) throw new ApiError(404, 'GRN not found'); if (!row) throw new ApiError(404, 'GRN not found');
return row; 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 getReceivablePoOrThrow = async (poId) => {
const po = await prisma.purchase_orders.findFirst({ const po = await prisma.purchase_orders.findFirst({
where: { id: BigInt(poId), deleted_at: null }, where: { id: BigInt(poId), deleted_at: null },
@ -430,25 +568,14 @@ const cancelGrn = async (id, payload, userId, requestId) => {
}; };
const getGrnPdf = async (id) => { const getGrnPdf = async (id) => {
const grn = sanitizeGrn(await getGrnOrThrow(id, { includeItems: true })); const grn = sanitizeGrn(await getGrnOrThrow(id, { forPdf: true }));
const lines = [ const company = await getCompanyForDocuments();
`GRN: ${grn.grn_number}`, const html = generateGrnHtml(buildGrnPdfPayload(grn, company));
`Date: ${grn.grn_date ? new Date(grn.grn_date).toISOString().slice(0, 10) : '-'}`, const buffer = await generatePdf(html);
`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}`
),
];
return { return {
filename: `${grn.grn_number.replace(/\//g, '-')}.pdf`, filename: `${grn.grn_number.replace(/\//g, '-')}.pdf`,
buffer: buildSimplePdf(lines), buffer,
}; };
}; };

View File

@ -3,7 +3,10 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog'); const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination'); const { getPagination } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode'); 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 { const {
EDITABLE_STATUSES, EDITABLE_STATUSES,
SUBMITTABLE_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) => { const toDateOnly = (value) => {
if (!value) return null; if (!value) return null;
const date = new Date(value); 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({ const po = await prisma.purchase_orders.findFirst({
where: { id: BigInt(id), deleted_at: null }, where: { id: BigInt(id), deleted_at: null },
include: includeItems ? poDetailInclude : poListInclude, include,
}); });
if (!po) throw new ApiError(404, 'Purchase order not found'); if (!po) throw new ApiError(404, 'Purchase order not found');
return po; 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) => const hasReceipts = (po) =>
(po.purchase_order_items || []).some((line) => Number(line.received_qty) > 0); (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 getPurchaseOrderPdf = async (id) => {
const po = sanitizePo(await getPoOrThrow(id, { includeItems: true })); const po = sanitizePo(await getPoOrThrow(id, { forPdf: true }));
const lines = [ const company = await getCompanyForDocuments();
`Purchase Order: ${po.po_number}`, const html = generatePoHtml(buildPoPdfPayload(po, company));
`Date: ${po.po_date ? new Date(po.po_date).toISOString().slice(0, 10) : '-'}`, const buffer = await generatePdf(html);
`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}`
),
];
return { return {
filename: `${po.po_number.replace(/\//g, '-')}.pdf`, filename: `${po.po_number.replace(/\//g, '-')}.pdf`,
buffer: buildSimplePdf(lines), buffer,
}; };
}; };

View File

@ -20,6 +20,7 @@ const sanitizeCompany = (row) => {
return { return {
id: row.id, id: row.id,
org_name: row.org_name, org_name: row.org_name,
gstin: row.gstin,
mobile: row.mobile, mobile: row.mobile,
email: row.email, email: row.email,
website: row.website, website: row.website,
@ -72,6 +73,7 @@ const updateCompany = async (payload, userId, requestId) => {
const data = { const data = {
...(payload.org_name !== undefined ? { org_name: payload.org_name || null } : {}), ...(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.mobile !== undefined ? { mobile: payload.mobile || null } : {}),
...(payload.email !== undefined ? { email: payload.email || null } : {}), ...(payload.email !== undefined ? { email: payload.email || null } : {}),
...(payload.website !== undefined ? { website: payload.website || null } : {}), ...(payload.website !== undefined ? { website: payload.website || null } : {}),
@ -179,7 +181,7 @@ const getCompanyForDocuments = async () => {
city: row.city || '', city: row.city || '',
state: row.state || '', state: row.state || '',
pincode: row.pincode || '', pincode: row.pincode || '',
gstin: '', gstin: row.gstin || '',
phone: row.mobile || '', phone: row.mobile || '',
email: row.email || '', email: row.email || '',
website: row.website || '', website: row.website || '',

View File

@ -3,6 +3,7 @@ const { masterName } = require('../masters/_shared/masters.validation');
const companyUpdateSchema = Joi.object({ const companyUpdateSchema = Joi.object({
org_name: masterName({ max: 200 }), org_name: masterName({ max: 200 }),
gstin: Joi.string().length(15).allow(null, '').optional(),
mobile: Joi.string().max(20).allow(null, '').optional(), mobile: Joi.string().max(20).allow(null, '').optional(),
email: Joi.string().email().max(200).allow(null, '').optional(), email: Joi.string().email().max(200).allow(null, '').optional(),
website: Joi.string().max(255).allow(null, '').optional(), website: Joi.string().max(255).allow(null, '').optional(),

View File

@ -1,16 +1,45 @@
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const formatDate = (value) => { const pad2 = (value) => String(value).padStart(2, '0');
if (!value) return '-';
const date = new Date(value); const toValidDate = (value) => {
if (Number.isNaN(date.getTime())) return '-'; 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 isDateOnlyString = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
const month = MONTHS[date.getMonth()];
const year = date.getFullYear();
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 }; module.exports = { formatDate };

View File

@ -67,10 +67,10 @@ const numberToWords = (value = 0) => {
const paiseWords = decimalPart ? twoDigitsToWords(decimalPart) : ''; const paiseWords = decimalPart ? twoDigitsToWords(decimalPart) : '';
if (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 }; module.exports = { numberToWords };

View File

@ -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 generatePdf = async (htmlContent) => {
const puppeteer = await import('puppeteer'); const puppeteer = await import('puppeteer');
const executablePath = resolveChromePath();
const browser = await puppeteer.default.launch({ const browser = await puppeteer.default.launch({
headless: true, headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'], ...(executablePath ? { executablePath } : {}),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
}); });
try { try {
@ -12,10 +27,10 @@ const generatePdf = async (htmlContent) => {
const pdfBuffer = await page.pdf({ const pdfBuffer = await page.pdf({
format: 'A4', format: 'A4',
printBackground: true, 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 { } finally {
await browser.close(); await browser.close();
} }

View File

@ -1,231 +1,531 @@
const { formatCurrency } = require('../helpers/formatCurrency'); const { formatCurrency } = require('../helpers/formatCurrency');
const { formatDate } = require('../helpers/formatDate'); const { formatDate } = require('../helpers/formatDate');
const { numberToWords } = require('../helpers/numberToWords'); const { numberToWords } = require('../helpers/numberToWords');
const {
escapeHtml,
getBaseStyles,
renderCompanyHeader,
renderPartyCard,
renderFootnote,
getDummyCompany,
} = require('../helpers/templateBase');
const getDummyGrnData = () => ({ 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: {
grn_number: 'GRN-2026-00018', grn_number: 'GRN/2026-27/00012',
grn_date: '2026-06-22', grn_date: '2026-07-14',
status: 'POSTED', status: 'Posted',
po_number: 'PO-2026-00047', po_number: 'PO/2026-27/00007',
vendor_invoice_no: 'SIS/INV/2026/441', vendor_invoice_no: 'TSL/INV/2026/441',
vendor_invoice_date: '2026-06-21', vendor_invoice_date: '2026-07-13',
vendor_invoice_amount: 254743.5, vendor_invoice_amount: 139943.75,
vehicle_no: 'MH-12-AB-4521', vehicle_no: 'TN-09-AB-4521',
lr_no: 'LR-77821', lr_no: 'LR-77821',
lr_date: '2026-06-21', lr_date: '2026-07-13',
received_by: 'Amit Sharma', received_by: 'Amit Sharma',
quality_checked_by: 'Priya Nair', quality_checked_by: 'Priya Nair',
remarks: 'All items inspected and accepted except partial rejection on line 3.', remarks: 'All items inspected and accepted except partial rejection on line 3.',
}, },
vendor: { vendor: {
vendor_name: 'Shree Industrial Supplies', vendor_name: 'Tata Steel Limited',
gstin: '27AAECS7788B1Z2', gstin: '20AABCT3456A1Z9',
address: 'Plot 17, MIDC Industrial Area', address: 'Bistupur Main Road',
city: 'Pune', city: 'Jamshedpur',
state: 'Maharashtra', state: 'Jharkhand',
pincode: '411019', pincode: '831001',
contact_name: 'Rajesh Patil',
phone: '+91 98220 12345',
}, },
warehouse: { warehouse: {
code: 'WH-NOI-01', name: 'Main warehouse',
name: 'Noida Main Warehouse', address: 'Guindy Industrial Estate',
address: 'Plot 9, Industrial Estate, Sector 63, Noida', city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
}, },
items: [ items: [
{ {
line_no: 1, line_no: 1,
item_code: 'ITM-MS-CH-100', item_name: 'TMT steel bars 12mm',
item_name: 'MS Channel 100x50 mm', item_code: 'ITM-TMT-12',
uom: 'KG', uom: 'MT',
ordered_qty: 1250, ordered_qty: 25,
previously_received_qty: 0, current_qty: 25,
current_qty: 1250, accepted_qty: 25,
accepted_qty: 1250,
rejected_qty: 0, rejected_qty: 0,
rate: 72.5, rate: 4250,
line_total: 90625, line_total: 106250,
}, },
{ {
line_no: 2, line_no: 2,
item_code: 'ITM-FST-M12', item_name: 'Cement OPC 53 grade',
item_name: 'Industrial Fasteners Set M12', item_code: 'ITM-CEM-53',
uom: 'BOX', uom: 'Bag',
ordered_qty: 40, ordered_qty: 500,
previously_received_qty: 0, current_qty: 500,
current_qty: 40, accepted_qty: 500,
accepted_qty: 40,
rejected_qty: 0, rejected_qty: 0,
rate: 1425, rate: 380,
line_total: 57000, line_total: 190000,
}, },
{ {
line_no: 3, line_no: 3,
item_code: 'ITM-WLD-E6013', item_name: 'River sand',
item_name: 'Welding Rod E6013', item_code: 'ITM-SND-01',
uom: 'BOX', remarks: '2 bags damaged in transit',
ordered_qty: 30, uom: 'CFT',
previously_received_qty: 0, ordered_qty: 1200,
current_qty: 30, current_qty: 1200,
accepted_qty: 28, accepted_qty: 1180,
rejected_qty: 2, rejected_qty: 20,
rate: 985, rate: 45,
line_total: 27580, line_total: 53100,
},
{
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: { totals: {
sub_total: 217205, accepted_value: 349350,
grand_total: 217205, rejected_value: 900,
grand_total: 349350,
}, },
generated_at: '14/07/2026 09:40',
}); });
const escapeHtml = (value = '') =>
String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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 generateGrnHtml = (inputData = getDummyGrnData()) => {
const data = 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 || []) const itemsRows = (data.items || [])
.map( .map((item, index) => {
(item, index) => ` const code = item.item_code
? `<div class="item-code">${escapeHtml(item.item_code)}</div>`
: '';
const remarks = item.remarks
? `<div class="item-note">*${escapeHtml(item.remarks)}</div>`
: '';
return `
<tr> <tr>
<td>${item.line_no || index + 1}</td> <td class="col-no">${item.line_no || index + 1}</td>
<td class="item-name"> <td class="col-item">
${escapeHtml(item.item_name)}<br /> <div class="item-name">${escapeHtml(item.item_name || '-')}</div>
<span style="color:#6b7280;font-size:10px;">${escapeHtml(item.item_code)}</span> ${code}
${remarks}
</td> </td>
<td>${escapeHtml(item.uom)}</td> <td class="col-qty text-right">${qtyDisplay(item.ordered_qty)}</td>
<td class="text-right">${Number(item.ordered_qty || 0).toFixed(2)}</td> <td class="col-qty text-right">${qtyDisplay(item.current_qty)}</td>
<td class="text-right">${Number(item.accepted_qty || 0).toFixed(2)}</td> <td class="col-qty text-right">${qtyDisplay(item.accepted_qty)}</td>
<td class="text-right">${Number(item.rejected_qty || 0).toFixed(2)}</td> <td class="col-qty text-right">${qtyDisplay(item.rejected_qty)}</td>
<td class="text-right">${formatCurrency(item.rate)}</td> <td class="col-uom">${escapeHtml(item.uom || '-')}</td>
<td class="text-right">${formatCurrency(item.line_total)}</td> <td class="col-rate text-right">${formatCurrency(item.rate)}</td>
<td class="col-amt text-right">${formatCurrency(item.line_total)}</td>
</tr> </tr>
` `;
) })
.join(''); .join('');
const docMetaHtml = ` const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode);
<div><strong>GRN No:</strong> ${escapeHtml(data.grn.grn_number)}</div> const warehouseAddress = joinParts(
<div><strong>Date:</strong> ${formatDate(data.grn.grn_date)}</div> warehouse.address,
<div><strong>PO Ref:</strong> ${escapeHtml(data.grn.po_number)}</div> warehouse.city,
<div style="margin-top: 4px;"><span class="status">${escapeHtml(data.grn.status)}</span></div> 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 ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Goods Receipt Note</title> <title>Goods Receipt Note ${escapeHtml(grn.grn_number || '')}</title>
<style>${getBaseStyles()}</style> <style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Helvetica, Arial, sans-serif;
color: #111111;
background: #ffffff;
font-size: 11px;
line-height: 1.45;
}
.document { width: 100%; padding: 0; }
.label {
color: #9a9a9a;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
}
.value { color: #111111; font-weight: 700; }
.text-right { text-align: right; }
.divider {
border: 0;
border-top: 1.5px solid #111111;
margin: 14px 0;
}
.divider-light {
border: 0;
border-top: 1px solid #d8d8d8;
margin: 14px 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.company-name {
margin: 0;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.02em;
}
.company-meta {
margin-top: 4px;
color: #555555;
font-size: 10.5px;
line-height: 1.5;
}
.title-block { text-align: right; min-width: 230px; }
.doc-title {
margin: 0;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.doc-number {
margin-top: 2px;
color: #555555;
font-size: 12px;
}
.meta-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.meta-row .value { margin-top: 2px; font-size: 12px; }
.party-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
margin-top: 4px;
}
.party-name {
margin-top: 4px;
font-size: 13px;
font-weight: 800;
}
.party-body {
margin-top: 2px;
color: #444444;
font-size: 10.5px;
line-height: 1.5;
}
.class-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.class-row .value { margin-top: 2px; font-size: 12px; }
table.items {
width: 100%;
border-collapse: collapse;
margin-top: 2px;
}
table.items thead th {
padding: 8px 3px;
border-bottom: 1.5px solid #111111;
color: #9a9a9a;
font-size: 8.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 600;
text-align: left;
}
table.items tbody td {
padding: 9px 3px;
border-bottom: 1px solid #e5e5e5;
vertical-align: top;
font-size: 10.5px;
}
.col-no { width: 4%; color: #666666; }
.col-item { width: 28%; }
.col-qty { width: 8%; }
.col-uom { width: 6%; }
.col-rate { width: 11%; }
.col-amt { width: 13%; }
.item-name { font-weight: 600; }
.item-code {
margin-top: 1px;
color: #888888;
font-size: 9px;
}
.item-note {
margin-top: 2px;
color: #888888;
font-size: 9.5px;
font-style: italic;
}
.summary-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.summary { width: 280px; }
.summary-row {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 3px 0;
color: #444444;
font-size: 11px;
}
.summary-row .amount { color: #111111; font-weight: 600; }
.grand-row {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 6px;
padding: 8px 0;
border-top: 1.5px solid #111111;
border-bottom: 1.5px solid #111111;
font-size: 14px;
font-weight: 800;
}
.amount-words {
margin-top: 10px;
border: 1px solid #cfcfcf;
background: #f7f7f7;
padding: 8px 10px;
}
.amount-words .words {
margin-top: 2px;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.notes { margin-top: 18px; }
.notes-block { margin-bottom: 12px; }
.notes-block .body {
margin-top: 4px;
color: #444444;
font-size: 10.5px;
line-height: 1.5;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
margin-top: 28px;
}
.sign-title { font-size: 11px; font-weight: 700; }
.sign-sub {
margin-top: 2px;
color: #666666;
font-size: 10px;
}
.footer {
margin-top: 24px;
padding-top: 10px;
border-top: 1px solid #d8d8d8;
text-align: center;
color: #9a9a9a;
font-size: 9.5px;
}
</style>
</head> </head>
<body> <body>
<div class="document"> <div class="document">
${renderCompanyHeader({ company: data.company, title: 'GOODS RECEIPT NOTE', docMetaHtml })} <div class="header">
<div>
<div class="party-grid"> <h1 class="company-name">${escapeHtml(company.name || 'Company')}</h1>
${renderPartyCard( <div class="company-meta">
'Received At', ${escapeHtml(companyAddressLine(company))}<br />
` ${escapeHtml(companyContactLine(company))}
<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> </div>
<div class="card"> <div class="title-block">
<div class="card-title">Receipt Details</div> <h2 class="doc-title">Goods receipt note</h2>
<div class="card-body"> <div class="doc-number">${escapeHtml(grn.grn_number || '-')}</div>
<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>
</div> </div>
<hr class="divider" />
<div class="meta-row">
<div>
<div class="label">Status</div>
<div class="value">${escapeHtml(grn.status || '-')}</div>
</div>
<div>
<div class="label">Date received</div>
<div class="value">${formatDate(grn.grn_date, { style: 'numeric' })}</div>
</div>
<div>
<div class="label">PO reference</div>
<div class="value">${escapeHtml(grn.po_number || '-')}</div>
</div>
</div>
<div class="party-row" style="margin-top: 18px;">
<div>
<div class="label">Vendor</div>
<div class="party-name">${escapeHtml(vendor.vendor_name || '-')}</div>
<div class="party-body">
${escapeHtml(vendorAddress || '-')}<br />
${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''}
</div>
</div>
<div>
<div class="label">Received at</div>
<div class="party-name">${escapeHtml(warehouse.name || '-')}</div>
<div class="party-body">${escapeHtml(warehouseAddress || '-')}</div>
</div>
</div>
<hr class="divider-light" />
<div class="class-row">
<div>
<div class="label">Vendor invoice</div>
<div class="value">${escapeHtml(grn.vendor_invoice_no || '-')}</div>
</div>
<div>
<div class="label">Invoice date</div>
<div class="value">${formatDate(grn.vendor_invoice_date, { style: 'numeric' })}</div>
</div>
<div>
<div class="label">Vehicle no</div>
<div class="value">${escapeHtml(grn.vehicle_no || '-')}</div>
</div>
<div>
<div class="label">LR no / date</div>
<div class="value">${escapeHtml(lrLine || '-')}</div>
</div>
</div>
<hr class="divider-light" />
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
<th style="width:5%;">S.No</th> <th class="col-no">#</th>
<th>Item</th> <th class="col-item">Item description</th>
<th style="width:8%;">UOM</th> <th class="col-qty text-right">Ordered</th>
<th style="width:10%;" class="text-right">Ordered</th> <th class="col-qty text-right">Received</th>
<th style="width:10%;" class="text-right">Accepted</th> <th class="col-qty text-right">Accepted</th>
<th style="width:9%;" class="text-right">Rejected</th> <th class="col-qty text-right">Rejected</th>
<th style="width:12%;" class="text-right">Rate</th> <th class="col-uom">UOM</th>
<th style="width:14%;" class="text-right">Amount</th> <th class="col-rate text-right">Rate</th>
<th class="col-amt text-right">Amount</th>
</tr> </tr>
</thead> </thead>
<tbody>${itemsRows}</tbody> <tbody>
${itemsRows}
</tbody>
</table> </table>
<div class="summary-wrap"> <div class="summary-wrap">
<div class="summary-box"> <div class="summary">
<table> <div class="summary-row">
<tr> <span>Accepted value</span>
<td>Accepted Value</td> <span class="amount">${formatCurrency(totals.accepted_value)}</span>
<td class="text-right">${formatCurrency(data.totals.sub_total)}</td> </div>
</tr> <div class="summary-row">
<tr> <span>Rejected value</span>
<td class="grand-total">Total Received Value</td> <span class="amount">${formatCurrency(totals.rejected_value)}</span>
<td class="text-right grand-total">${formatCurrency(data.totals.grand_total)}</td> </div>
</tr> ${
</table> grn.vendor_invoice_amount != null && grn.vendor_invoice_amount !== ''
? `<div class="summary-row">
<span>Vendor invoice amount</span>
<span class="amount">${formatCurrency(grn.vendor_invoice_amount)}</span>
</div>`
: ''
}
<div class="grand-row">
<span>Grand total</span>
<span>${formatCurrency(totals.grand_total)}</span>
</div>
<div class="amount-words">
<div class="label">Amount in words</div>
<div class="words">${escapeHtml(numberToWords(totals.grand_total))}</div>
</div>
</div> </div>
</div> </div>
<div class="amount-words">Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}</div> <div class="notes">
${
<div class="terms"> grn.remarks
<h3>Remarks</h3> ? `<div class="notes-block">
<div>${escapeHtml(data.grn.remarks)}</div> <div class="label">Remarks</div>
<div class="body">${escapeHtml(grn.remarks)}</div>
</div>`
: ''
}
</div> </div>
${renderFootnote()} <div class="sign-row">
<div>
<div class="sign-title">Received by</div>
<div class="sign-sub">${escapeHtml(grn.received_by || '-')}</div>
</div>
<div>
<div class="sign-title">Quality checked by</div>
<div class="sign-sub">${escapeHtml(grn.quality_checked_by || '-')}</div>
</div>
<div>
<div class="sign-title">Authorised signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
</div>
<div class="footer">
Generated ${escapeHtml(generatedAt)} · This is a system-generated goods receipt note and does not require a physical stamp.
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -4,365 +4,471 @@ const { numberToWords } = require('../helpers/numberToWords');
const getDummyPoData = () => ({ const getDummyPoData = () => ({
company: { company: {
name: 'Acme Infra & Engineering Pvt. Ltd.', name: 'Bharat Industries Pvt. Ltd.',
address: '3rd Floor, Business Tower, Sector 62', address: 'Plot 14, Guindy Industrial Estate',
city: 'Noida', city: 'Chennai',
state: 'Uttar Pradesh', state: 'Tamil Nadu',
pincode: '201301', pincode: '600032',
gstin: '09AABCA1234K1Z7', gstin: '33AABCB1234D1Z5',
phone: '+91 120 4400 221', phone: '+91 44 4000 1200',
email: 'procurement@acmeinfra.in', email: 'procurement@bharatindustries.in',
}, },
po: { po: {
po_number: 'PO-2026-00047', po_number: 'PO/2026-27/00007',
po_date: '2026-06-20', po_date: '2026-07-07',
expected_delivery_date: '2026-06-30', expected_delivery_date: '2026-07-21',
status: 'Approved', status: 'Approved',
payment_term: '30 Days from Invoice Date', po_type: 'Raw material',
delivery_term: 'Door Delivery - Main Plant', revision_no: 1,
payment_term: '30 days credit',
delivery_term: 'FOB (freight on board)',
terms_and_conditions: terms_and_conditions:
'Material must conform to approved specifications. Any variation should be approved in writing before dispatch.', 'Payment as per agreed terms. Goods must match PO specification. Shortages or damages must be reported within 48 hours of delivery.',
remarks: 'Please mention PO number in invoice and delivery challan.', remarks: 'Priority delivery required for production line restart on 22 Jul 2026.',
}, },
vendor: { vendor: {
vendor_name: 'Shree Industrial Supplies', vendor_name: 'Tata Steel Limited',
gstin: '27AAECS7788B1Z2', gstin: '20AABCT3456A1Z9',
address: 'Plot 17, MIDC Industrial Area', address: 'Bistupur Main Road',
city: 'Pune', city: 'Jamshedpur',
state: 'Maharashtra', state: 'Jharkhand',
pincode: '411019', pincode: '831001',
contact_name: 'Rajesh Patil',
phone: '+91 98220 12345',
}, },
ship_to: {
name: 'Chennai manufacturing plant',
address: 'Guindy Industrial Estate',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
},
brand: { name: 'Tata Tiscon' },
warehouse: { name: 'Main warehouse' },
items: [ items: [
{ {
line_no: 1, line_no: 1,
item_name: 'MS Channel 100x50 mm', item_name: 'TMT steel bars 12mm',
uom: 'KG', uom: 'MT',
ordered_qty: 1250, ordered_qty: 25,
rate: 72.5, rate: 4250,
discount_pct: 2,
gst_rate_pct: 18, gst_rate_pct: 18,
taxable_amount: 90625, line_total: 124215,
tax_amount: 16312.5,
line_total: 106937.5,
}, },
{ {
line_no: 2, line_no: 2,
item_name: 'Industrial Fasteners Set M12', item_name: 'Cement OPC 53 grade',
uom: 'BOX', uom: 'Bag',
ordered_qty: 40, ordered_qty: 500,
rate: 1425, rate: 380,
gst_rate_pct: 18, discount_pct: 0,
taxable_amount: 57000, gst_rate_pct: 28,
tax_amount: 10260, line_total: 243200,
line_total: 67260,
}, },
{ {
line_no: 3, line_no: 3,
item_name: 'Welding Rod E6013', item_name: 'River sand',
uom: 'BOX', remarks: 'Confirm bag count on delivery',
ordered_qty: 30, uom: 'CFT',
rate: 985, ordered_qty: 1200,
gst_rate_pct: 12, rate: 45,
taxable_amount: 29550, discount_pct: 5,
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, gst_rate_pct: 5,
taxable_amount: 42000, line_total: 53865,
tax_amount: 2100,
line_total: 44100,
}, },
], ],
totals: { totals: {
sub_total: 219175, sub_total: 119775,
tax_total: 32218.5, tax_total: 19768.75,
freight_charges: 2500, freight_charges: 750,
other_charges: 850, other_charges: 150,
grand_total: 254743.5, discount_amount: 500,
grand_total: 139943.75,
}, },
generated_at: '2026-07-07 14:32 IST',
}); });
const escapeHtml = (value = '') => const escapeHtml = (value = '') =>
String(value) String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
const calculateTaxSplit = (taxTotal) => { const joinParts = (...parts) => parts.map((p) => String(p || '').trim()).filter(Boolean).join(', ');
const halfTax = Number((Number(taxTotal || 0) / 2).toFixed(2));
return { cgst: halfTax, sgst: halfTax }; 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 generatePoHtml = (inputData = getDummyPoData()) => { const generatePoHtml = (inputData = getDummyPoData()) => {
const data = inputData || getDummyPoData(); const data = inputData || getDummyPoData();
const taxSplit = calculateTaxSplit(data.totals.tax_total); const company = data.company || {};
const po = data.po || {};
const vendor = data.vendor || {};
const shipTo = data.ship_to || {};
const brand = data.brand || {};
const warehouse = data.warehouse || {};
const totals = data.totals || {};
const itemsRows = (data.items || []) const itemsRows = (data.items || [])
.map( .map((item, index) => {
(item, index) => ` const remarks = item.remarks
? `<div class="item-note">*${escapeHtml(item.remarks)}</div>`
: '';
return `
<tr> <tr>
<td>${item.line_no || index + 1}</td> <td class="col-no">${item.line_no || index + 1}</td>
<td class="item-name">${escapeHtml(item.item_name)}</td> <td class="col-item">
<td>${escapeHtml(item.uom)}</td> <div class="item-name">${escapeHtml(item.item_name || '-')}</div>
<td class="text-right">${Number(item.ordered_qty || 0).toFixed(2)}</td> ${remarks}
<td class="text-right">${formatCurrency(item.rate)}</td> </td>
<td class="text-right">${Number(item.gst_rate_pct || 0).toFixed(2)}%</td> <td class="col-qty text-right">${qtyDisplay(item.ordered_qty)}</td>
<td class="text-right">${formatCurrency(item.line_total)}</td> <td class="col-uom">${escapeHtml(item.uom || '-')}</td>
<td class="col-rate text-right">${formatCurrency(item.rate)}</td>
<td class="col-disc text-right">${Number(item.discount_pct || 0).toFixed(0)}%</td>
<td class="col-gst text-right">${Number(item.gst_rate_pct || 0).toFixed(0)}%</td>
<td class="col-amt text-right">${formatCurrency(item.line_total)}</td>
</tr> </tr>
` `;
) })
.join(''); .join('');
const discountValue = Number(totals.discount_amount || 0);
const discountFormatted = discountValue > 0 ? `-${formatCurrency(discountValue)}` : formatCurrency(0);
const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode);
const shipAddress = joinParts(shipTo.address, shipTo.city, shipTo.state, shipTo.pincode);
const generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' });
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Purchase Order ${escapeHtml(po.po_number || '')}</title>
<title>Purchase Order</title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: Arial, Helvetica, sans-serif; font-family: Helvetica, Arial, sans-serif;
color: #111827; color: #111111;
background: #ffffff; background: #ffffff;
font-size: 12px; font-size: 11px;
line-height: 1.5; line-height: 1.45;
} }
.document { .document { width: 100%; padding: 0; }
width: 100%; .muted { color: #8a8a8a; }
padding: 0; .label {
color: #9a9a9a;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
} }
.value { color: #111111; font-weight: 700; }
.text-right { text-align: right; }
.divider {
border: 0;
border-top: 1.5px solid #111111;
margin: 14px 0;
}
.divider-light {
border: 0;
border-top: 1px solid #d8d8d8;
margin: 14px 0;
}
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 24px;
} }
.brand { .company-name {
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; margin: 0;
font-size: 16px; font-size: 20px;
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; font-weight: 800;
letter-spacing: 0.7px; letter-spacing: -0.02em;
} }
.doc-meta { .company-meta {
margin-top: 6px; margin-top: 4px;
border: 1px solid #bfdbfe; color: #555555;
border-radius: 8px; font-size: 10.5px;
padding: 8px 10px; line-height: 1.5;
background: #eff6ff;
} }
.status { .title-block { text-align: right; min-width: 210px; }
display: inline-block; .doc-title {
background: #1d4ed8; margin: 0;
color: #ffffff; font-size: 26px;
font-size: 10px; font-weight: 800;
font-weight: 700; letter-spacing: -0.02em;
padding: 2px 8px;
border-radius: 10px;
} }
.party-grid { .doc-number {
margin-top: 14px; margin-top: 2px;
color: #555555;
font-size: 12px;
}
.meta-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.meta-row .value { margin-top: 2px; font-size: 12px; }
.party-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 28px;
margin-top: 4px;
} }
.card { .party-name {
border: 1px solid #dbeafe; margin-top: 4px;
border-radius: 8px; font-size: 13px;
overflow: hidden; font-weight: 800;
} }
.card-title { .party-body {
background: #1d4ed8; margin-top: 2px;
color: #ffffff; color: #444444;
padding: 8px 10px; font-size: 10.5px;
font-weight: 700; line-height: 1.5;
} }
.card-body { .party-term {
padding: 10px; margin-top: 6px;
color: #444444;
font-size: 10.5px;
} }
table {
.class-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.class-row .value { margin-top: 2px; font-size: 12px; }
table.items {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-top: 2px;
} }
.items { table.items thead th {
margin-top: 12px; padding: 8px 4px;
border: 1px solid #d1d5db; border-bottom: 1.5px solid #111111;
border-radius: 8px; color: #9a9a9a;
overflow: hidden; font-size: 9px;
} letter-spacing: 0.08em;
.items thead th { text-transform: uppercase;
background: #1d4ed8; font-weight: 600;
color: #ffffff;
padding: 8px 6px;
font-size: 11px;
text-align: left; text-align: left;
} }
.items tbody td { table.items tbody td {
padding: 8px 6px; padding: 10px 4px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e5e5;
vertical-align: top;
font-size: 11px;
} }
.items tbody tr:nth-child(even) { .col-no { width: 4%; color: #666666; }
background: #f9fafb; .col-item { width: 36%; }
} .col-qty { width: 8%; }
.text-right { .col-uom { width: 8%; }
text-align: right; .col-rate { width: 12%; }
} .col-disc { width: 8%; }
.item-name { .col-gst { width: 8%; }
width: 34%; .col-amt { width: 16%; }
.item-name { font-weight: 600; }
.item-note {
margin-top: 2px;
color: #888888;
font-size: 9.5px;
font-style: italic;
} }
.summary-wrap { .summary-wrap {
margin-top: 12px; margin-top: 16px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.summary-box { .summary {
width: 340px; width: 280px;
border: 1px solid #dbeafe;
border-radius: 8px;
overflow: hidden;
} }
.summary-box table td { .summary-row {
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; display: flex;
justify-content: space-between; justify-content: space-between;
color: #6b7280; gap: 24px;
padding: 3px 0;
color: #444444;
font-size: 11px;
}
.summary-row .amount { color: #111111; font-weight: 600; }
.grand-row {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 6px;
padding: 8px 0;
border-top: 1.5px solid #111111;
border-bottom: 1.5px solid #111111;
font-size: 14px;
font-weight: 800;
}
.amount-words {
margin-top: 10px;
border: 1px solid #cfcfcf;
background: #f7f7f7;
padding: 8px 10px;
}
.amount-words .words {
margin-top: 2px;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.notes {
margin-top: 18px;
}
.notes-block { margin-bottom: 12px; }
.notes-block .body {
margin-top: 4px;
color: #444444;
font-size: 10.5px;
line-height: 1.5;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
margin-top: 28px;
}
.sign-title {
font-size: 11px;
font-weight: 700;
}
.sign-sub {
margin-top: 2px;
color: #666666;
font-size: 10px; font-size: 10px;
} }
.page-number:before {
content: "Page " counter(page); .footer {
margin-top: 24px;
padding-top: 10px;
border-top: 1px solid #d8d8d8;
text-align: center;
color: #9a9a9a;
font-size: 9.5px;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="document"> <div class="document">
<div class="header"> <div class="header">
<div class="brand"> <div>
<div class="logo">AI</div> <h1 class="company-name">${escapeHtml(company.name || 'Company')}</h1>
<div class="company"> <div class="company-meta">
<h1>${escapeHtml(data.company.name)}</h1> ${escapeHtml(companyAddressLine(company))}<br />
<div class="meta"> ${escapeHtml(companyContactLine(company))}
${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> </div>
<div class="title-block"> <div class="title-block">
<h2 class="title">PURCHASE ORDER</h2> <h2 class="doc-title">Purchase order</h2>
<div class="doc-meta"> <div class="doc-number">${escapeHtml(po.po_number || '-')}</div>
<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> </div>
<div class="party-grid"> <hr class="divider" />
<div class="card">
<div class="card-title">From</div> <div class="meta-row">
<div class="card-body"> <div>
<strong>${escapeHtml(data.company.name)}</strong><br /> <div class="label">Status</div>
${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}<br /> <div class="value">${escapeHtml(po.status || '-')}</div>
${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}<br />
GSTIN: ${escapeHtml(data.company.gstin)}<br />
Phone: ${escapeHtml(data.company.phone)}
</div>
</div> </div>
<div class="card"> <div>
<div class="card-title">To</div> <div class="label">Date issued</div>
<div class="card-body"> <div class="value">${formatDate(po.po_date, { style: 'numeric' })}</div>
<strong>${escapeHtml(data.vendor.vendor_name)}</strong><br /> </div>
${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}<br /> <div>
${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}<br /> <div class="label">Expected delivery</div>
GSTIN: ${escapeHtml(data.vendor.gstin)}<br /> <div class="value">${formatDate(po.expected_delivery_date, { style: 'numeric' })}</div>
Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)})
</div>
</div> </div>
</div> </div>
<div class="party-row" style="margin-top: 18px;">
<div>
<div class="label">Vendor</div>
<div class="party-name">${escapeHtml(vendor.vendor_name || '-')}</div>
<div class="party-body">
${escapeHtml(vendorAddress || '-')}<br />
${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''}
</div>
${
po.payment_term
? `<div class="party-term">Payment term: ${escapeHtml(po.payment_term)}</div>`
: ''
}
</div>
<div>
<div class="label">Ship to</div>
<div class="party-name">${escapeHtml(shipTo.name || '-')}</div>
<div class="party-body">${escapeHtml(shipAddress || '-')}</div>
${
po.delivery_term
? `<div class="party-term">Delivery term: ${escapeHtml(po.delivery_term)}</div>`
: ''
}
</div>
</div>
<hr class="divider-light" />
<div class="class-row">
<div>
<div class="label">PO type</div>
<div class="value">${escapeHtml(po.po_type || '-')}</div>
</div>
<div>
<div class="label">Brand</div>
<div class="value">${escapeHtml(brand.name || '-')}</div>
</div>
<div>
<div class="label">Warehouse</div>
<div class="value">${escapeHtml(warehouse.name || '-')}</div>
</div>
<div>
<div class="label">Revision</div>
<div class="value">${escapeHtml(String(po.revision_no ?? 0))}</div>
</div>
</div>
<hr class="divider-light" />
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
<th style="width: 6%;">S.No</th> <th class="col-no">#</th>
<th>Item</th> <th class="col-item">Item description</th>
<th style="width: 9%;">UOM</th> <th class="col-qty text-right">Qty</th>
<th style="width: 11%;" class="text-right">Qty</th> <th class="col-uom">UOM</th>
<th style="width: 14%;" class="text-right">Rate</th> <th class="col-rate text-right">Rate</th>
<th style="width: 10%;" class="text-right">GST%</th> <th class="col-disc text-right">Disc %</th>
<th style="width: 16%;" class="text-right">Amount</th> <th class="col-gst text-right">GST</th>
<th class="col-amt text-right">Amount</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -371,49 +477,70 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
</table> </table>
<div class="summary-wrap"> <div class="summary-wrap">
<div class="summary-box"> <div class="summary">
<table> <div class="summary-row">
<tr> <span>Taxable amount</span>
<td>Sub Total</td> <span class="amount">${formatCurrency(totals.sub_total)}</span>
<td class="text-right">${formatCurrency(data.totals.sub_total)}</td> </div>
</tr> <div class="summary-row">
<tr> <span>Tax (GST)</span>
<td>CGST</td> <span class="amount">${formatCurrency(totals.tax_total)}</span>
<td class="text-right">${formatCurrency(taxSplit.cgst)}</td> </div>
</tr> <div class="summary-row">
<tr> <span>Freight charges</span>
<td>SGST</td> <span class="amount">${formatCurrency(totals.freight_charges)}</span>
<td class="text-right">${formatCurrency(taxSplit.sgst)}</td> </div>
</tr> <div class="summary-row">
<tr> <span>Other charges</span>
<td>Freight</td> <span class="amount">${formatCurrency(totals.other_charges)}</span>
<td class="text-right">${formatCurrency(data.totals.freight_charges)}</td> </div>
</tr> <div class="summary-row">
<tr> <span>Discount</span>
<td>Other Charges</td> <span class="amount">${discountFormatted}</span>
<td class="text-right">${formatCurrency(data.totals.other_charges)}</td> </div>
</tr> <div class="grand-row">
<tr> <span>Grand total</span>
<td class="grand-total">Grand Total</td> <span>${formatCurrency(totals.grand_total)}</span>
<td class="text-right grand-total">${formatCurrency(data.totals.grand_total)}</td> </div>
</tr> <div class="amount-words">
</table> <div class="label">Amount in words</div>
<div class="words">${escapeHtml(numberToWords(totals.grand_total))}</div>
</div>
</div> </div>
</div> </div>
<div class="amount-words">Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}</div> <div class="notes">
${
<div class="terms"> po.terms_and_conditions
<h3>Terms & Conditions</h3> ? `<div class="notes-block">
<div><strong>Payment Term:</strong> ${escapeHtml(data.po.payment_term)}</div> <div class="label">Terms and conditions</div>
<div><strong>Delivery Term:</strong> ${escapeHtml(data.po.delivery_term)}</div> <div class="body">${escapeHtml(po.terms_and_conditions)}</div>
<div style="margin-top: 6px;">${escapeHtml(data.po.terms_and_conditions)}</div> </div>`
<div style="margin-top: 6px;"><strong>Remarks:</strong> ${escapeHtml(data.po.remarks)}</div> : ''
}
${
po.remarks
? `<div class="notes-block">
<div class="label">Remarks</div>
<div class="body">${escapeHtml(po.remarks)}</div>
</div>`
: ''
}
</div> </div>
<div class="footnote"> <div class="sign-row">
<span>This is a computer generated document.</span> <div>
<span class="page-number"></span> <div class="sign-title">Authorised signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
<div>
<div class="sign-title">Accepted by vendor</div>
<div class="sign-sub">${escapeHtml(vendor.vendor_name || '')}</div>
</div>
</div>
<div class="footer">
Generated ${escapeHtml(generatedAt)} · This is a system-generated purchase order and does not require a physical stamp.
</div> </div>
</div> </div>
</body> </body>