Tạo PDF
Tổng quan
Dịch vụ Hóa đơn tạo ra các tài liệu PDF tuân thủ các yêu cầu hiển thị hóa đơn điện tử của Việt Nam, bao gồm thông tin công ty, chi tiết các mặt hàng, mã QR để xác minh và định dạng tiếng Việt chính xác.
Bố cục PDF
┌────────────────────────────────────────────────────────────┐
│ LOGO │
│ HÓA ĐƠN GIÁ TRỊ GIA TĂNG │
│ (VAT INVOICE) │
├────────────────────────────────────────────────────────────┤
│ Mẫu số: 01GTKT0/001 Ký hiệu: C25TAA │
│ Số: 00000001 Ngày: 20/01/2025 │
├────────────────────────────────────────────────────────────┤
│ ĐƠN VỊ BÁN HÀNG (SELLER) │
│ Tên: Công ty TNHH ABC │
│ MST: 0123456789 │
│ Địa chỉ: 123 Nguyễn Huệ, Q1, TP.HCM │
│ Điện thoại: 028-1234-5678 │
├────────────────────────────────────────────────────────────┤
│ NGƯỜI MUA HÀNG (BUYER) │
│ Tên: Khách hàng XYZ │
│ MST: 9876543210 │
│ Địa chỉ: 456 Lê Lợi, Q1, TP.HCM │
├────────────────────────────────────────────────────────────┤
│ STT │ Tên hàng hóa │ ĐVT │ SL │ Đơn giá │ Thành tiền │
│──────┼───────────────┼─────┼──────┼───────────┼─────────────│
│ 1 │ Sản phẩm A │ Cái │ 2 │ 100,000 │ 200,000 │
│ 2 │ Dịch vụ B │ Lần │ 1 │ 50,000 │ 45,000 │
│ │ │ │ │ CK: 10% │ -5,000 │
├────────────────────────────────────────────────────────────┤
│ Cộng tiền hàng: 245,000 VND │
│ Thuế GTGT (10%): 24,500 VND │
│ Tổng cộng: 269,500 VND │
├────────────────────────────────────────────────────────────┤
│ Số tiền bằng chữ: Hai trăm sáu mươi chín nghìn │
│ năm trăm đồng │
├────────────────────────────────────────────────────────────┤
│ ┌─────────┐ │
│ │ QR CODE │ Mã CQT: ABC123DEF456 │
│ │ │ Ngày ký: 20/01/2025 10:00:00 │
│ └─────────┘ │
└────────────────────────────────────────────────────────────┘PdfGeneratorService
typescript
import PDFDocument from 'pdfkit';
import QRCode from 'qrcode';
@injectable()
class PdfGeneratorService {
/**
* Tạo PDF từ dữ liệu hóa đơn
*/
async generatePdf(invoice: Invoice): Promise<Buffer> {
const doc = new PDFDocument({
size: 'A4',
margin: 50,
bufferPages: true,
});
// Đăng ký font tiếng Việt
doc.registerFont('Vietnamese', 'fonts/NotoSans-Regular.ttf');
doc.font('Vietnamese');
// Tạo các phần của PDF
await this.addHeader(doc, invoice);
await this.addSellerInfo(doc, invoice);
await this.addBuyerInfo(doc, invoice);
await this.addItemsTable(doc, invoice.items);
await this.addTotals(doc, invoice);
await this.addAmountInWords(doc, invoice.grandTotal);
await this.addQrCode(doc, invoice);
await this.addFooter(doc, invoice);
doc.end();
return this.docToBuffer(doc);
}
private async addHeader(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
// Logo
if (invoice.merchantLogo) {
doc.image(invoice.merchantLogo, 50, 45, { width: 100 });
}
// Tiêu đề
doc.fontSize(18)
.text('HÓA ĐƠN GIÁ TRỊ GIA TĂNG', { align: 'center' })
.fontSize(12)
.text('(VAT INVOICE)', { align: 'center' })
.moveDown();
// Thông tin hóa đơn
doc.fontSize(10)
.text(`Mẫu số: ${invoice.templateCode}`, { align: 'left' })
.text(`Ký hiệu: ${invoice.templateSymbol}`, { align: 'right', y: doc.y - 15 })
.text(`Số: ${invoice.invoiceNumber}`, { align: 'left' })
.text(`Ngày: ${this.formatDate(invoice.issueDate)}`, { align: 'right', y: doc.y - 15 })
.moveDown();
}
private async addSellerInfo(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
doc.fontSize(11)
.text('ĐƠN VỊ BÁN HÀNG (Seller)', { underline: true })
.fontSize(10)
.text(`Tên đơn vị: ${invoice.sellerName}`)
.text(`Mã số thuế: ${invoice.sellerTaxCode}`)
.text(`Địa chỉ: ${invoice.sellerAddress}`);
if (invoice.sellerPhone) {
doc.text(`Điện thoại: ${invoice.sellerPhone}`);
}
if (invoice.sellerBankAccount) {
doc.text(`Số TK: ${invoice.sellerBankAccount} - ${invoice.sellerBankName}`);
}
doc.moveDown();
}
private async addBuyerInfo(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
doc.fontSize(11)
.text('NGƯỜI MUA HÀNG (Buyer)', { underline: true })
.fontSize(10)
.text(`Tên người mua: ${invoice.buyerName}`);
if (invoice.buyerTaxCode) {
doc.text(`Mã số thuế: ${invoice.buyerTaxCode}`);
}
if (invoice.buyerAddress) {
doc.text(`Địa chỉ: ${invoice.buyerAddress}`);
}
doc.moveDown();
}
private async addItemsTable(doc: PDFKit.PDFDocument, items: InvoiceItem[]): Promise<void> {
const tableTop = doc.y;
const colWidths = [30, 200, 50, 50, 80, 90];
const headers = ['STT', 'Tên hàng hóa, dịch vụ', 'ĐVT', 'SL', 'Đơn giá', 'Thành tiền'];
// Vẽ header
let x = 50;
doc.fontSize(9).font('Vietnamese-Bold');
headers.forEach((header, i) => {
doc.text(header, x, tableTop, { width: colWidths[i], align: 'center' });
x += colWidths[i];
});
// Vẽ đường kẻ header
doc.moveTo(50, tableTop + 15).lineTo(550, tableTop + 15).stroke();
// Vẽ các mặt hàng
doc.font('Vietnamese');
let y = tableTop + 20;
items.forEach((item, index) => {
x = 50;
const values = [
(index + 1).toString(),
item.productName,
item.unit,
item.quantity.toString(),
this.formatCurrency(item.unitPrice),
this.formatCurrency(item.totalBeforeVat),
];
values.forEach((value, i) => {
doc.text(value, x, y, {
width: colWidths[i],
align: i === 0 || i >= 3 ? 'right' : 'left',
});
x += colWidths[i];
});
y += 15;
// Hiển thị chiết khấu nếu có
if (item.discountAmount > 0) {
doc.text(`Chiết khấu: ${item.discountPercent}%`, 250, y);
doc.text(`-${this.formatCurrency(item.discountAmount)}`, 460, y, {
width: 90,
align: 'right',
});
y += 15;
}
});
// Vẽ đường kẻ dưới
doc.moveTo(50, y).lineTo(550, y).stroke();
doc.y = y + 10;
}
private async addTotals(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
const rightCol = 350;
doc.fontSize(10)
.text('Cộng tiền hàng:', rightCol)
.text(this.formatCurrency(invoice.totalBeforeVat) + ' VND', rightCol + 100, doc.y - 12, {
align: 'right', width: 100,
});
// Chi tiết VAT
doc.text(`Thuế GTGT (${invoice.vatRate}%):`, rightCol)
.text(this.formatCurrency(invoice.totalVat) + ' VND', rightCol + 100, doc.y - 12, {
align: 'right', width: 100,
});
doc.fontSize(11).font('Vietnamese-Bold')
.text('Tổng cộng tiền thanh toán:', rightCol)
.text(this.formatCurrency(invoice.grandTotal) + ' VND', rightCol + 100, doc.y - 14, {
align: 'right', width: 100,
});
doc.font('Vietnamese');
doc.moveDown();
}
private async addAmountInWords(doc: PDFKit.PDFDocument, amount: number): Promise<void> {
const words = numberToVietnameseWords(amount);
doc.fontSize(10)
.text(`Số tiền viết bằng chữ: ${words}`)
.moveDown();
}
private async addQrCode(doc: PDFKit.PDFDocument, invoice: Invoice): Promise<void> {
// Tạo nội dung mã QR
const qrContent = [
invoice.sellerTaxCode,
invoice.invoiceNumber,
this.formatDateShort(invoice.issueDate),
invoice.grandTotal.toString(),
invoice.totalVat.toString(),
invoice.cqtCode || '',
].join('|');
// Tạo hình ảnh mã QR
const qrDataUrl = await QRCode.toDataURL(qrContent, {
width: 100,
margin: 1,
});
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
doc.image(qrBuffer, 50, doc.y, { width: 80 });
// Thêm mã CQT
doc.fontSize(9)
.text(`Mã CQT: ${invoice.cqtCode || 'Đang chờ'}`, 140, doc.y - 60)
.text(`Ngày ký: ${this.formatDateTime(invoice.signedAt)}`, 140);
}
private formatCurrency(amount: number): string {
return amount.toLocaleString('vi-VN');
}
private formatDate(date: Date): string {
return date.toLocaleDateString('vi-VN');
}
private formatDateTime(date: Date): string {
return date.toLocaleString('vi-VN');
}
}Nội dung Mã QR
Cấu trúc Mã QR
MST_SELLER|INVOICE_NUMBER|ISSUE_DATE|TOTAL|VAT|CQT_CODE
Ví dụ:
0123456789|C25TAA/00000001|20250120|269500|24500|ABC123DEF456Các Trường Mã QR
| Vị trí | Trường | Mô tả |
|---|---|---|
| 1 | MST Seller | Mã số thuế của người bán |
| 2 | Invoice Number | Số hóa đơn đầy đủ |
| 3 | Issue Date | Định dạng: YYYYMMDD |
| 4 | Total Amount | Tổng cộng |
| 5 | VAT Amount | Tổng VAT |
| 6 | CQT Code | Mã xác thực cơ quan thuế |
Tạo Mã QR
typescript
import QRCode from 'qrcode';
async function generateInvoiceQR(invoice: Invoice): Promise<Buffer> {
const content = [
invoice.sellerTaxCode,
invoice.invoiceNumber,
formatDateForQR(invoice.issueDate),
invoice.grandTotal.toString(),
invoice.totalVat.toString(),
invoice.cqtCode,
].join('|');
return QRCode.toBuffer(content, {
errorCorrectionLevel: 'M',
type: 'png',
width: 200,
margin: 2,
});
}
function formatDateForQR(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, '');
}Yêu cầu Font Chữ
Hỗ trợ Font Tiếng Việt
typescript
// Đăng ký font với hỗ trợ dấu tiếng Việt đầy đủ
doc.registerFont('Vietnamese', 'fonts/NotoSans-Regular.ttf');
doc.registerFont('Vietnamese-Bold', 'fonts/NotoSans-Bold.ttf');
doc.registerFont('Vietnamese-Italic', 'fonts/NotoSans-Italic.ttf');
// Các font thay thế hỗ trợ tiếng Việt tốt:
// - Be Vietnam Pro
// - Roboto
// - Open Sans
// - InterCấu hình Font
typescript
const fontConfig = {
regular: path.join(__dirname, '../fonts/NotoSans-Regular.ttf'),
bold: path.join(__dirname, '../fonts/NotoSans-Bold.ttf'),
italic: path.join(__dirname, '../fonts/NotoSans-Italic.ttf'),
};Cấu hình Mẫu
Cấu trúc Mẫu
typescript
interface PdfTemplate {
id: string;
name: string;
logo?: {
path: string;
width: number;
position: { x: number; y: number };
};
colors: {
primary: string;
secondary: string;
text: string;
};
fonts: {
title: { name: string; size: number };
body: { name: string; size: number };
small: { name: string; size: number };
};
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
sections: {
header: boolean;
sellerInfo: boolean;
buyerInfo: boolean;
itemsTable: boolean;
totals: boolean;
amountInWords: boolean;
qrCode: boolean;
footer: boolean;
};
}Mẫu Mặc định
typescript
const defaultTemplate: PdfTemplate = {
id: 'default',
name: 'Hóa đơn GTGT Tiêu chuẩn',
colors: {
primary: '#1a1a1a',
secondary: '#666666',
text: '#333333',
},
fonts: {
title: { name: 'Vietnamese-Bold', size: 18 },
body: { name: 'Vietnamese', size: 10 },
small: { name: 'Vietnamese', size: 8 },
},
margins: { top: 50, bottom: 50, left: 50, right: 50 },
sections: {
header: true,
sellerInfo: true,
buyerInfo: true,
itemsTable: true,
totals: true,
amountInWords: true,
qrCode: true,
footer: true,
},
};Lưu trữ PDF
Tùy chọn Lưu trữ
typescript
interface PdfStorageConfig {
type: 'local' | 'minio' | 's3';
bucket?: string;
pathPrefix?: string;
expiryDays?: number;
}
// Lưu trữ cục bộ
const localConfig: PdfStorageConfig = {
type: 'local',
pathPrefix: '/var/data/invoices/pdf',
};
// Lưu trữ MinIO
const minioConfig: PdfStorageConfig = {
type: 'minio',
bucket: 'invoices',
pathPrefix: 'pdf',
expiryDays: 3650, // 10 năm
};Dịch vụ Lưu trữ
typescript
@injectable()
class InvoicePdfStorageService {
async savePdf(invoice: Invoice, pdfBuffer: Buffer): Promise<string> {
const filename = this.generateFilename(invoice);
if (this.config.type === 'minio') {
await this.minioClient.putObject(
this.config.bucket,
filename,
pdfBuffer,
{ 'Content-Type': 'application/pdf' },
);
return `${this.config.bucket}/${filename}`;
}
const filePath = path.join(this.config.pathPrefix, filename);
await fs.writeFile(filePath, pdfBuffer);
return filePath;
}
private generateFilename(invoice: Invoice): string {
const year = invoice.issueDate.getFullYear();
const month = String(invoice.issueDate.getMonth() + 1).padStart(2, '0');
return `${year}/${month}/${invoice.invoiceNumber.replace('/', '_')}.pdf`;
}
}Xử lý Lỗi
| Lỗi | Mã | Mô tả |
|---|---|---|
PDF_GENERATION_FAILED | E001 | Thất bại khi tạo PDF |
FONT_NOT_FOUND | E002 | Font yêu cầu không khả dụng |
QR_GENERATION_FAILED | E003 | Thất bại khi tạo mã QR |
STORAGE_FAILED | E004 | Thất bại khi lưu PDF |
TEMPLATE_NOT_FOUND | E005 | Không tìm thấy mẫu PDF |