Re_Backend/src/services/pdf.service.ts

335 lines
15 KiB
TypeScript

import puppeteer from 'puppeteer';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { ClaimInvoice } from '@models/ClaimInvoice';
import { DealerClaimDetails } from '@models/DealerClaimDetails';
import { DealerProposalDetails } from '@models/DealerProposalDetails';
import { DealerCompletionExpense } from '@models/DealerCompletionExpense';
import { ClaimInvoiceItem } from '@models/ClaimInvoiceItem';
import { ActivityType } from '@models/ActivityType';
import { amountToWords } from '@utils/currencyUtils';
import { findDealerLocally } from './dealer.service';
import path from 'path';
import fs from 'fs';
import logger from '@utils/logger';
import dayjs from 'dayjs';
export class PdfService {
constructor() { }
async generateInvoicePdf(requestId: string): Promise<Buffer> {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
const request = await WorkflowRequest.findByPk(requestId);
const invoice = await ClaimInvoice.findOne({ where: { requestId } });
const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } });
const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } });
const completionExpenses = await DealerCompletionExpense.findAll({
where: { requestId },
order: [['createdAt', 'ASC']]
});
const invoiceItems = await ClaimInvoiceItem.findAll({
where: { requestId },
order: [['slNo', 'ASC']]
});
const dealer = await findDealerLocally(claimDetails?.dealerCode, claimDetails?.dealerEmail);
// Resolve taxationType
let taxationType = 'GST';
if (claimDetails?.activityType) {
const activity = await ActivityType.findOne({ where: { title: claimDetails?.activityType } });
taxationType = activity?.taxationType || (claimDetails.activityType.toLowerCase().includes('non') ? 'Non GST' : 'GST');
}
if (!request || !invoice) {
throw new Error('Request or Invoice not found');
}
const htmlContent = this.getInvoiceHtmlTemplate({
request,
invoice,
claimDetails,
proposalDetails,
completionExpenses,
invoiceItems,
dealer,
taxationType
});
await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
const pdfBuffer = Buffer.from(await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' }
}));
return pdfBuffer;
} catch (error) {
logger.error(`[PdfService] Error generating PDF for request ${requestId}:`, error);
throw error;
} finally {
await browser.close();
}
}
private getInvoiceHtmlTemplate(data: any): string {
const { request, invoice, dealer, claimDetails, completionExpenses = [], invoiceItems = [], taxationType } = data;
const qrImage = invoice.qrImage ? `data:image/png;base64,${invoice.qrImage}` : '';
const logoUrl = `{{LOGO_URL}}`;
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
let tableRows = '';
if (invoiceItems && invoiceItems.length > 0) {
// Use persisted invoice items (matches PWC payload exactly)
tableRows = invoiceItems.map((item: any) => `
<tr>
<td>${item.slNo}</td>
<td>${item.description}</td>
${!isNonGst ? `<td>${item.hsnCd}</td>` : ''}
<td>${Number(item.assAmt).toFixed(2)}</td>
<td>0.00</td>
<td>${item.unit}</td>
${!isNonGst ? `<td>${Number(item.assAmt).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${Number(item.igstAmt) > 0 ? Number(item.gstRt).toFixed(2) : '0.00'}</td>
<td>${Number(item.igstAmt).toFixed(2)}</td>
<td>${Number(item.cgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.cgstAmt).toFixed(2)}</td>
<td>${Number(item.sgstAmt) > 0 ? (Number(item.gstRt) / 2).toFixed(2) : '0.00'}</td>
<td>${Number(item.sgstAmt).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? item.assAmt : item.totItemVal).toFixed(2)}</td>
</tr>
`).join('');
} else if (completionExpenses.length > 0) {
// Group expenses by HSN/SAC and GST Rate for clubbed display
const grouped: Record<string, any> = {};
completionExpenses.forEach((item: any) => {
const hsn = item.hsnCode || 'N/A';
const rate = Number(item.gstRate || 0);
const key = isNonGst ? 'NON_GST' : `${hsn}_${rate}`;
if (!grouped[key]) {
grouped[key] = {
hsn,
rate,
description: item.description || 'Expense',
amount: 0,
igstRate: item.igstRate || 0,
igstAmt: 0,
cgstRate: item.cgstRate || 0,
cgstAmt: 0,
sgstRate: item.sgstRate || 0,
sgstAmt: 0,
totalAmt: 0
};
}
const qty = Number(item.quantity || 1);
grouped[key].amount += Number(item.amount || 0) * qty;
grouped[key].igstAmt += Number(item.igstAmt || 0);
grouped[key].cgstAmt += Number(item.cgstAmt || 0);
grouped[key].sgstAmt += Number(item.sgstAmt || 0);
grouped[key].totalAmt += Number(item.totalAmt || 0);
});
tableRows = Object.values(grouped).map((item: any) => `
<tr>
<td>EXPENSE</td>
<td>${item.description}</td>
${!isNonGst ? `<td>${item.hsn}</td>` : ''}
<td>${Number(item.amount).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
${!isNonGst ? `<td>${Number(item.amount).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${Number(item.igstRate || 0).toFixed(2)}</td>
<td>${Number(item.igstAmt || 0).toFixed(2)}</td>
<td>${Number(item.cgstRate || 0).toFixed(2)}</td>
<td>${Number(item.cgstAmt || 0).toFixed(2)}</td>
<td>${Number(item.sgstRate || 0).toFixed(2)}</td>
<td>${Number(item.sgstAmt || 0).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? item.amount : item.totalAmt).toFixed(2)}</td>
</tr>
`).join('');
} else {
tableRows = `
<tr>
<td>CLAIM</td>
<td>${request.title || 'Warranty Claim'}</td>
${!isNonGst ? '<td>998881</td>' : ''}
<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>
<td>0.00</td>
<td>EA</td>
${!isNonGst ? `<td>${Number(invoice.taxableValue || invoice.amount || 0).toFixed(2)}</td>` : ''}
${!isNonGst ? `
<td>${invoice.igstTotal > 0 ? (Number(invoice.igstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.igstTotal || 0).toFixed(2)}</td>
<td>${invoice.cgstTotal > 0 ? (Number(invoice.cgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.cgstTotal || 0).toFixed(2)}</td>
<td>${invoice.sgstTotal > 0 ? (Number(invoice.sgstTotal) / Number(invoice.taxableValue || invoice.amount || 1) * 100).toFixed(2) : '0.00'}</td>
<td>${Number(invoice.sgstTotal || 0).toFixed(2)}</td>
` : ''}
<td>${Number(isNonGst ? (invoice.taxableValue || invoice.amount) : invoice.amount || 0).toFixed(2)}</td>
</tr>
`;
}
let totalTaxable = 0;
let totalTax = 0;
let grandTotal = 0;
if (invoiceItems && invoiceItems.length > 0) {
invoiceItems.forEach((item: any) => {
totalTaxable += Number(item.assAmt || 0);
if (!isNonGst) {
totalTax += Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
grandTotal += Number(item.totItemVal || 0);
} else {
grandTotal += Number(item.assAmt || 0);
}
});
} else if (completionExpenses.length > 0) {
completionExpenses.forEach((item: any) => {
const qty = Number(item.quantity || 1);
const baseAmt = Number(item.amount || 0) * qty;
totalTaxable += baseAmt;
if (!isNonGst) {
const taxAmt = Number(item.igstAmt || 0) + Number(item.cgstAmt || 0) + Number(item.sgstAmt || 0);
totalTax += taxAmt;
grandTotal += (baseAmt + taxAmt);
} else {
grandTotal += baseAmt;
}
});
} else {
totalTaxable = Number(invoice.taxableValue || invoice.amount || 0);
if (!isNonGst) {
totalTax = Number(invoice.igstTotal || 0) + Number(invoice.cgstTotal || 0) + Number(invoice.sgstTotal || 0);
grandTotal = Number(invoice.amount || 0);
} else {
grandTotal = totalTaxable;
}
}
const totalValueInWords = amountToWords(grandTotal);
const totalTaxInWords = amountToWords(totalTax);
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; color: #333; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
.logo { height: 40px; }
.qr-code { width: 150px; height: 150px; }
.irn-details { font-size: 11px; margin-top: 10px; }
.irn-details div { margin-bottom: 4px; }
.title { text-align: center; font-size: 24px; font-weight: bold; margin: 30px 0; border-top: 1px solid #ccc; padding-top: 20px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-bottom: 30px; font-size: 12px; }
.info-section h3 { font-size: 14px; margin-bottom: 10px; }
.info-row { display: flex; margin-bottom: 5px; }
.info-label { width: 120px; font-weight: bold; }
.info-value { flex: 1; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 10px; }
th, td { border: 1px solid #333; padding: 6px; text-align: left; }
th { background-color: #f2f2f2; }
.totals { margin-top: 20px; width: 300px; margin-left: auto; font-size: 12px; }
.totals-row { display: flex; justify-content: space-between; padding: 5px 0; }
.totals-row.grand-total { border-top: 2px solid #333; font-weight: bold; margin-top: 10px; }
.words { margin-top: 30px; font-size: 11px; font-style: italic; }
.footer { margin-top: 60px; text-align: right; font-size: 12px; }
.signature { margin-top: 40px; border-top: 1px solid #333; width: 200px; margin-left: auto; text-align: center; padding-top: 5px; }
</style>
</head>
<body>
<div class="header">
<div>
<img src="${logoUrl}" class="logo" />
<div class="irn-details">
${invoice.irn ? `<div><strong>IRN No :</strong> ${invoice.irn}</div>` : ''}
${invoice.ackNo ? `<div><strong>Ack No :</strong> ${invoice.ackNo}</div>` : ''}
${invoice.ackDate ? `<div><strong>Ack Date & Time :</strong> ${dayjs(invoice.ackDate).format('YYYY-MM-DD HH:mm:ss')}</div>` : ''}
</div>
</div>
${qrImage ? `<img src="${qrImage}" class="qr-code" />` : ''}
</div>
<div class="title">${isNonGst ? 'CLAIM INVOICE' : 'CLAIM TAX INVOICE'}</div>
<div class="info-grid">
<div class="info-section">
<div class="info-row"><div class="info-label">Customer Name</div><div class="info-value">Royal Enfield</div></div>
<div class="info-row"><div class="info-label">Customer GSTIN</div><div class="info-value">33AAACE3882D1ZZ</div></div>
<div class="info-row"><div class="info-label">Customer Address</div><div class="info-value">State Highway 48, Vallam Industrial Corridor, Vallakottai Chennai, Tamil Nadu - 631604</div></div>
</div>
<div class="info-section">
<div class="info-row"><div class="info-label">Dealer</div><div class="info-value">${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Store</div><div class="info-value">${dealer?.dealerName || claimDetails?.dealerName || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Supplier GSTIN</div><div class="info-value">${dealer?.gstin || 'N/A'}</div></div>
<br/>
<div class="info-row"><div class="info-label">POS</div><div class="info-value">${invoice.placeOfSupply || dealer?.state || 'Test State'}</div></div>
<div class="info-row"><div class="info-label">Claim Request Number</div><div class="info-value">${request.requestNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice No.</div><div class="info-value">${invoice.invoiceNumber || 'N/A'}</div></div>
<div class="info-row"><div class="info-label">Claim Invoice Date</div><div class="info-value">${invoice.invoiceDate ? dayjs(invoice.invoiceDate).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}</div></div>
<div class="info-row"><div class="info-label">Last Approval Date</div><div class="info-value">${invoice.generatedAt ? dayjs(invoice.generatedAt).format('DD-MM-YYYY') : dayjs().format('DD-MM-YYYY')}</div></div>
</div>
</div>
<table>
<thead>
<tr>
<th>Part</th>
<th>Description</th>
${!isNonGst ? '<th>HSN/SAC</th>' : ''}
<th>${isNonGst ? 'Amount' : 'Base Amount'}</th>
<th>Disc</th>
<th>UOM</th>
${!isNonGst ? '<th>Taxable Value</th>' : ''}
${!isNonGst ? `
<th>IGST %</th>
<th>IGST</th>
<th>CGST %</th>
<th>CGST</th>
<th>SGST %</th>
<th>SGST</th>
` : ''}
<th>Total</th>
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>
<div class="totals">
<div class="totals-row"><span>${isNonGst ? 'TOTAL AMOUNT' : 'TAXABLE TOTAL'}:</span><span>${totalTaxable.toFixed(2)}</span></div>
${!isNonGst ? `<div class="totals-row"><span>TOTAL TAX:</span><span>${totalTax.toFixed(2)}</span></div>` : ''}
<div class="totals-row grand-total"><span>GRAND TOTAL:</span><span>${grandTotal.toFixed(2)}</span></div>
</div>
<div class="words">
<div><strong>TOTAL VALUE IN WORDS:</strong> Rupees ${totalValueInWords}</div>
${!isNonGst ? `<div><strong>TOTAL TAX IN WORDS:</strong> Rupees ${totalTaxInWords}</div>` : ''}
</div>
<div class="footer">
<p>For ${dealer?.name || 'Authorised Dealer'}</p>
<div class="signature">Authorised signatory</div>
</div>
</body>
</html>
`;
}
}
export const pdfService = new PdfService();