From 969647333f82469a6cd3d6712632989b0060caaa Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Tue, 14 Jul 2026 09:48:59 +0530 Subject: [PATCH] GWM : pdf desigh for pd and grn --- BACKEND_TASKS.md | 4 +- prisma/schema.prisma | 1 + scripts/patch-company-email-settings.sql | 4 + scripts/patch-company-gstin.sql | 4 + src/docs/settings-routes.yaml | 1 + src/modules/grn/grn.service.js | 165 +++- .../purchase-orders.service.js | 152 +++- src/modules/settings/settings.service.js | 4 +- src/modules/settings/settings.validation.js | 1 + src/utils/pdf/helpers/formatDate.js | 45 +- src/utils/pdf/helpers/numberToWords.js | 4 +- src/utils/pdf/pdfGenerator.js | 21 +- src/utils/pdf/templates/grn.template.js | 620 +++++++++++---- src/utils/pdf/templates/po.template.js | 715 +++++++++++------- 14 files changed, 1234 insertions(+), 507 deletions(-) create mode 100644 scripts/patch-company-gstin.sql 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)}
+
+
+

${escapeHtml(company.name || 'Company')}

+
+ ${escapeHtml(companyAddressLine(company))}
+ ${escapeHtml(companyContactLine(company))}
-
-
Receipt Details
-
-
Received By${escapeHtml(data.grn.received_by)}
-
Quality Checked By${escapeHtml(data.grn.quality_checked_by)}
-
Warehouse${escapeHtml(data.warehouse.code)}
-
PO Reference${escapeHtml(data.grn.po_number)}
-
+
+

Goods receipt note

+
${escapeHtml(grn.grn_number || '-')}
+
+ +
+
+
Status
+
${escapeHtml(grn.status || '-')}
+
+
+
Date received
+
${formatDate(grn.grn_date, { style: 'numeric' })}
+
+
+
PO reference
+
${escapeHtml(grn.po_number || '-')}
+
+
+ +
+
+
Vendor
+
${escapeHtml(vendor.vendor_name || '-')}
+
+ ${escapeHtml(vendorAddress || '-')}
+ ${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''} +
+
+
+
Received at
+
${escapeHtml(warehouse.name || '-')}
+
${escapeHtml(warehouseAddress || '-')}
+
+
+ +
+ +
+
+
Vendor invoice
+
${escapeHtml(grn.vendor_invoice_no || '-')}
+
+
+
Invoice date
+
${formatDate(grn.vendor_invoice_date, { style: 'numeric' })}
+
+
+
Vehicle no
+
${escapeHtml(grn.vehicle_no || '-')}
+
+
+
LR no / date
+
${escapeHtml(lrLine || '-')}
+
+
+ +
+ - - - - - - - - + + + + + + + + + - ${itemsRows} + + ${itemsRows} +
S.NoItemUOMOrderedAcceptedRejectedRateAmount#Item descriptionOrderedReceivedAcceptedRejectedUOMRateAmount
-
- - - - - - - - - -
Accepted Value${formatCurrency(data.totals.sub_total)}
Total Received Value${formatCurrency(data.totals.grand_total)}
+
+
+ Accepted value + ${formatCurrency(totals.accepted_value)} +
+
+ Rejected value + ${formatCurrency(totals.rejected_value)} +
+ ${ + grn.vendor_invoice_amount != null && grn.vendor_invoice_amount !== '' + ? `
+ Vendor invoice amount + ${formatCurrency(grn.vendor_invoice_amount)} +
` + : '' + } +
+ Grand total + ${formatCurrency(totals.grand_total)} +
+
+
Amount in words
+
${escapeHtml(numberToWords(totals.grand_total))}
+
-
Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}
- -
-

Remarks

-
${escapeHtml(data.grn.remarks)}
+
+ ${ + grn.remarks + ? `
+
Remarks
+
${escapeHtml(grn.remarks)}
+
` + : '' + }
- ${renderFootnote()} +
+
+
Received by
+
${escapeHtml(grn.received_by || '-')}
+
+
+
Quality checked by
+
${escapeHtml(grn.quality_checked_by || '-')}
+
+
+
Authorised signatory
+
${escapeHtml(company.name || '')}
+
+
+ +
diff --git a/src/utils/pdf/templates/po.template.js b/src/utils/pdf/templates/po.template.js index 64adb18..6a26526 100644 --- a/src/utils/pdf/templates/po.template.js +++ b/src/utils/pdf/templates/po.template.js @@ -4,365 +4,471 @@ 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', + 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', }, po: { - po_number: 'PO-2026-00047', - po_date: '2026-06-20', - expected_delivery_date: '2026-06-30', + po_number: 'PO/2026-27/00007', + po_date: '2026-07-07', + expected_delivery_date: '2026-07-21', status: 'Approved', - payment_term: '30 Days from Invoice Date', - delivery_term: 'Door Delivery - Main Plant', + po_type: 'Raw material', + revision_no: 1, + payment_term: '30 days credit', + delivery_term: 'FOB (freight on board)', 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.', + 'Payment as per agreed terms. Goods must match PO specification. Shortages or damages must be reported within 48 hours of delivery.', + remarks: 'Priority delivery required for production line restart on 22 Jul 2026.', }, 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', }, + 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: [ { line_no: 1, - item_name: 'MS Channel 100x50 mm', - uom: 'KG', - ordered_qty: 1250, - rate: 72.5, + item_name: 'TMT steel bars 12mm', + uom: 'MT', + ordered_qty: 25, + rate: 4250, + discount_pct: 2, gst_rate_pct: 18, - taxable_amount: 90625, - tax_amount: 16312.5, - line_total: 106937.5, + line_total: 124215, }, { 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, + item_name: 'Cement OPC 53 grade', + uom: 'Bag', + ordered_qty: 500, + rate: 380, + discount_pct: 0, + gst_rate_pct: 28, + line_total: 243200, }, { 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, + item_name: 'River sand', + remarks: 'Confirm bag count on delivery', + uom: 'CFT', + ordered_qty: 1200, + rate: 45, + discount_pct: 5, gst_rate_pct: 5, - taxable_amount: 42000, - tax_amount: 2100, - line_total: 44100, + line_total: 53865, }, ], totals: { - sub_total: 219175, - tax_total: 32218.5, - freight_charges: 2500, - other_charges: 850, - grand_total: 254743.5, + sub_total: 119775, + tax_total: 19768.75, + freight_charges: 750, + other_charges: 150, + discount_amount: 500, + grand_total: 139943.75, }, + generated_at: '2026-07-07 14:32 IST', }); const escapeHtml = (value = '') => - String(value) + String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); -const calculateTaxSplit = (taxTotal) => { - const halfTax = Number((Number(taxTotal || 0) / 2).toFixed(2)); - return { cgst: halfTax, sgst: halfTax }; +const 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 generatePoHtml = (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 || []) - .map( - (item, index) => ` + .map((item, index) => { + const remarks = item.remarks + ? `
*${escapeHtml(item.remarks)}
` + : ''; + return ` - ${item.line_no || index + 1} - ${escapeHtml(item.item_name)} - ${escapeHtml(item.uom)} - ${Number(item.ordered_qty || 0).toFixed(2)} - ${formatCurrency(item.rate)} - ${Number(item.gst_rate_pct || 0).toFixed(2)}% - ${formatCurrency(item.line_total)} + ${item.line_no || index + 1} + +
${escapeHtml(item.item_name || '-')}
+ ${remarks} + + ${qtyDisplay(item.ordered_qty)} + ${escapeHtml(item.uom || '-')} + ${formatCurrency(item.rate)} + ${Number(item.discount_pct || 0).toFixed(0)}% + ${Number(item.gst_rate_pct || 0).toFixed(0)}% + ${formatCurrency(item.line_total)} - ` - ) + `; + }) .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 ` - - Purchase Order + Purchase Order ${escapeHtml(po.po_number || '')}
-
- -
-

${escapeHtml(data.company.name)}

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

${escapeHtml(company.name || 'Company')}

+
+ ${escapeHtml(companyAddressLine(company))}
+ ${escapeHtml(companyContactLine(company))}
-

PURCHASE ORDER

-
-
PO No: ${escapeHtml(data.po.po_number)}
-
Date: ${formatDate(data.po.po_date)}
-
Delivery: ${formatDate(data.po.expected_delivery_date)}
-
${escapeHtml(data.po.status)}
-
+

Purchase order

+
${escapeHtml(po.po_number || '-')}
-
-
-
From
-
- ${escapeHtml(data.company.name)}
- ${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}
- ${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}
- GSTIN: ${escapeHtml(data.company.gstin)}
- Phone: ${escapeHtml(data.company.phone)} -
+
+ +
+
+
Status
+
${escapeHtml(po.status || '-')}
-
-
To
-
- ${escapeHtml(data.vendor.vendor_name)}
- ${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}
- ${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}
- GSTIN: ${escapeHtml(data.vendor.gstin)}
- Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)}) -
+
+
Date issued
+
${formatDate(po.po_date, { style: 'numeric' })}
+
+
+
Expected delivery
+
${formatDate(po.expected_delivery_date, { style: 'numeric' })}
+
+
+
Vendor
+
${escapeHtml(vendor.vendor_name || '-')}
+
+ ${escapeHtml(vendorAddress || '-')}
+ ${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''} +
+ ${ + po.payment_term + ? `
Payment term: ${escapeHtml(po.payment_term)}
` + : '' + } +
+
+
Ship to
+
${escapeHtml(shipTo.name || '-')}
+
${escapeHtml(shipAddress || '-')}
+ ${ + po.delivery_term + ? `
Delivery term: ${escapeHtml(po.delivery_term)}
` + : '' + } +
+
+ +
+ +
+
+
PO type
+
${escapeHtml(po.po_type || '-')}
+
+
+
Brand
+
${escapeHtml(brand.name || '-')}
+
+
+
Warehouse
+
${escapeHtml(warehouse.name || '-')}
+
+
+
Revision
+
${escapeHtml(String(po.revision_no ?? 0))}
+
+
+ +
+ - - - - - - - + + + + + + + + @@ -371,49 +477,70 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
S.NoItemUOMQtyRateGST%Amount#Item descriptionQtyUOMRateDisc %GSTAmount
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Sub Total${formatCurrency(data.totals.sub_total)}
CGST${formatCurrency(taxSplit.cgst)}
SGST${formatCurrency(taxSplit.sgst)}
Freight${formatCurrency(data.totals.freight_charges)}
Other Charges${formatCurrency(data.totals.other_charges)}
Grand Total${formatCurrency(data.totals.grand_total)}
+
+
+ Taxable amount + ${formatCurrency(totals.sub_total)} +
+
+ Tax (GST) + ${formatCurrency(totals.tax_total)} +
+
+ Freight charges + ${formatCurrency(totals.freight_charges)} +
+
+ Other charges + ${formatCurrency(totals.other_charges)} +
+
+ Discount + ${discountFormatted} +
+
+ Grand total + ${formatCurrency(totals.grand_total)} +
+
+
Amount in words
+
${escapeHtml(numberToWords(totals.grand_total))}
+
-
Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}
- -
-

Terms & Conditions

-
Payment Term: ${escapeHtml(data.po.payment_term)}
-
Delivery Term: ${escapeHtml(data.po.delivery_term)}
-
${escapeHtml(data.po.terms_and_conditions)}
-
Remarks: ${escapeHtml(data.po.remarks)}
+
+ ${ + po.terms_and_conditions + ? `
+
Terms and conditions
+
${escapeHtml(po.terms_and_conditions)}
+
` + : '' + } + ${ + po.remarks + ? `
+
Remarks
+
${escapeHtml(po.remarks)}
+
` + : '' + }
-
- This is a computer generated document. - +
+
+
Authorised signatory
+
${escapeHtml(company.name || '')}
+
+
+
Accepted by vendor
+
${escapeHtml(vendor.vendor_name || '')}
+
+
+ +