Skip to content

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|ABC123DEF456

Các Trường Mã QR

Vị tríTrườngMô tả
1MST SellerMã số thuế của người bán
2Invoice NumberSố hóa đơn đầy đủ
3Issue DateĐịnh dạng: YYYYMMDD
4Total AmountTổng cộng
5VAT AmountTổng VAT
6CQT CodeMã 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
// - Inter

Cấ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ỗiMô tả
PDF_GENERATION_FAILEDE001Thất bại khi tạo PDF
FONT_NOT_FOUNDE002Font yêu cầu không khả dụng
QR_GENERATION_FAILEDE003Thất bại khi tạo mã QR
STORAGE_FAILEDE004Thất bại khi lưu PDF
TEMPLATE_NOT_FOUNDE005Không tìm thấy mẫu PDF

Tài liệu Liên quan

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.