Skip to content

Đánh số Hóa đơn

Tổng quan

Hóa đơn điện tử Việt Nam yêu cầu đánh số tuần tự trong mỗi mẫu hóa đơn. Các số phải liên tiếp không có khoảng trống, và bất kỳ số nào bị hủy phải được ghi lại đúng cách.

Định dạng Số

[Ký hiệu Mẫu]/[Số Tuần tự]

Ví dụ: C25TAA/00000001

C25TAA = Ký hiệu Mẫu (do merchant chỉ định)
00000001 = Số tuần tự (8 chữ số, đệm số 0)

Quy tắc Đánh số

Quy tắcMô tả
Tuần tựSố phải liên tiếp (1, 2, 3...)
Không Khoảng trốngKhoảng trống yêu cầu bản ghi hủy
Theo MẫuChuỗi riêng biệt cho mỗi mẫu hóa đơn
Đệm số 0Số có 8 chữ số với các số 0 đứng đầu
Đặt lạiCó thể tùy chọn đặt lại hàng năm

Mẫu Hóa đơn

Định dạng Ký hiệu Mẫu

[Vị trí 1][Năm 2 chữ số][Loại Mẫu][Ký tự Tuần tự]

Vị trí 1: C = Doanh nghiệp phát hành
          T = Cơ quan thuế tạo
Năm:      25 = 2025
Loại:     T = Hóa đơn Điện tử
          G = Hóa đơn GTGT
Tuần tự:  AA, AB, AC... (do merchant định nghĩa)

Ví dụ: C25TAA
- C: Doanh nghiệp phát hành
- 25: Năm 2025
- T: Loại Hóa đơn Điện tử
- AA: Mẫu đầu tiên

Cấu hình Mẫu

typescript
interface InvoiceTemplate {
  id: string;
  merchantId: string;

  // Định danh mẫu
  templateCode: string;      // ví dụ: "1" (từ cơ quan thuế)
  templateSymbol: string;    // ví dụ: "C25TAA"
  modelNumber: string;       // ví dụ: "01GTKT0/001"

  // Đặt tên
  templateName: string;      // Tên hiển thị
  invoiceType: InvoiceType;  // 01GTKT, 02GTTT, v.v.

  // Đánh số
  currentNumber: number;     // Số đã sử dụng cuối cùng
  startNumber: number;       // Số bắt đầu (thường là 1)
  maxNumber: number;         // Tối đa trước khi có mẫu mới
  resetAnnually: boolean;    // Đặt lại vào năm mới

  // Trạng thái
  status: 'active' | 'inactive' | 'exhausted';
  registeredAt: Date;        // Đăng ký với cơ quan thuế
  expiresAt?: Date;          // Hết hạn mẫu
}

InvoiceNumberingService

typescript
@injectable()
class InvoiceNumberingService {
  constructor(
    private templateRepository: InvoiceTemplateRepository,
    private redis: Redis,
  ) {}

  /**
   * Lấy số hóa đơn tiếp theo cho một mẫu
   * An toàn luồng sử dụng các thao tác nguyên tử Redis
   */
  async getNextNumber(templateId: string): Promise<string> {
    const template = await this.templateRepository.findById(templateId);

    if (!template) {
      throw new TemplateNotFoundError(templateId);
    }

    if (template.status !== 'active') {
      throw new TemplateInactiveError(templateId);
    }

    // Tăng nguyên tử sử dụng Redis
    const key = `invoice:number:${templateId}`;
    const nextNumber = await this.redis.incr(key);

    // Kiểm tra nếu mẫu đã hết số
    if (nextNumber > template.maxNumber) {
      await this.templateRepository.updateStatus(templateId, 'exhausted');
      throw new TemplateExhaustedError(templateId);
    }

    // Cập nhật cơ sở dữ liệu (async, để lưu trữ)
    await this.templateRepository.updateCurrentNumber(templateId, nextNumber);

    // Định dạng: C25TAA/00000001
    return `${template.templateSymbol}/${String(nextNumber).padStart(8, '0')}`;
  }

  /**
   * Đặt trước một dải số để xử lý hàng loạt
   */
  async reserveRange(
    templateId: string,
    count: number,
  ): Promise<NumberRange> {
    const template = await this.templateRepository.findById(templateId);

    if (!template) {
      throw new TemplateNotFoundError(templateId);
    }

    const key = `invoice:number:${templateId}`;
    const endNumber = await this.redis.incrby(key, count);
    const startNumber = endNumber - count + 1;

    // Kiểm tra nếu bất kỳ số nào vượt quá tối đa
    if (endNumber > template.maxNumber) {
      // Rollback
      await this.redis.decrby(key, count);
      throw new InsufficientNumbersError(templateId, count);
    }

    await this.templateRepository.updateCurrentNumber(templateId, endNumber);

    return {
      templateId,
      templateSymbol: template.templateSymbol,
      startNumber,
      endNumber,
      numbers: Array.from(
        { length: count },
        (_, i) => `${template.templateSymbol}/${String(startNumber + i).padStart(8, '0')}`,
      ),
    };
  }

  /**
   * Xác thực chuỗi số để kiểm toán
   */
  async validateSequence(templateId: string): Promise<SequenceValidation> {
    const template = await this.templateRepository.findById(templateId);
    const invoices = await this.invoiceRepository.findByTemplate(templateId, {
      orderBy: 'invoiceNumber',
    });

    const gaps: number[] = [];
    const duplicates: number[] = [];
    let prevNumber = 0;

    for (const invoice of invoices) {
      const number = this.extractNumber(invoice.invoiceNumber);

      if (number === prevNumber) {
        duplicates.push(number);
      } else if (number !== prevNumber + 1) {
        // Phát hiện khoảng trống
        for (let i = prevNumber + 1; i < number; i++) {
          gaps.push(i);
        }
      }

      prevNumber = number;
    }

    return {
      templateId,
      isValid: gaps.length === 0 && duplicates.length === 0,
      totalInvoices: invoices.length,
      gaps,
      duplicates,
      lastNumber: prevNumber,
    };
  }

  /**
   * Ghi lại số đã hủy (để xử lý khoảng trống)
   */
  async recordCancellation(invoiceNumber: string): Promise<void> {
    const number = this.extractNumber(invoiceNumber);
    const templateSymbol = this.extractTemplateSymbol(invoiceNumber);

    const template = await this.templateRepository.findBySymbol(templateSymbol);

    await this.cancelledNumberRepository.create({
      templateId: template.id,
      invoiceNumber,
      number,
      cancelledAt: new Date(),
    });
  }

  private extractNumber(invoiceNumber: string): number {
    const parts = invoiceNumber.split('/');
    return parseInt(parts[1], 10);
  }

  private extractTemplateSymbol(invoiceNumber: string): string {
    return invoiceNumber.split('/')[0];
  }
}

Luồng Tạo Số

Xử lý Đồng thời

Các Thao tác Nguyên tử Redis

typescript
// Số đơn lẻ - tăng nguyên tử
const nextNumber = await redis.incr(`invoice:number:${templateId}`);

// Đặt trước dải số - tăng nguyên tử N
const endNumber = await redis.incrby(`invoice:number:${templateId}`, count);

// Với Lua script cho logic phức tạp
const luaScript = `
  local key = KEYS[1]
  local max = tonumber(ARGV[1])
  local current = redis.call('INCR', key)

  if current > max then
    redis.call('DECR', key)
    return -1
  end

  return current
`;

const result = await redis.eval(luaScript, 1, key, template.maxNumber);

Đồng bộ Cơ sở dữ liệu

typescript
// Đồng bộ Redis với cơ sở dữ liệu khi khởi động
async function initializeNumberSequence(templateId: string): Promise<void> {
  const template = await templateRepository.findById(templateId);
  const lastInvoice = await invoiceRepository.findLastByTemplate(templateId);

  const dbNumber = lastInvoice
    ? extractNumber(lastInvoice.invoiceNumber)
    : template.startNumber - 1;

  const redisKey = `invoice:number:${templateId}`;
  const redisNumber = await redis.get(redisKey);

  // Sử dụng số lớn hơn trong hai số
  const currentNumber = Math.max(dbNumber, parseInt(redisNumber || '0', 10));

  await redis.set(redisKey, currentNumber.toString());
  await templateRepository.updateCurrentNumber(templateId, currentNumber);
}

Đặt lại Hàng năm

Cấu hình Đặt lại

typescript
interface AnnualResetConfig {
  enabled: boolean;
  resetDay: number;      // Ngày trong tháng (1-31)
  resetMonth: number;    // Tháng (1-12)
  newTemplateSymbol: (year: number, currentSymbol: string) => string;
}

const resetConfig: AnnualResetConfig = {
  enabled: true,
  resetDay: 1,
  resetMonth: 1,  // 1 tháng 1
  newTemplateSymbol: (year, current) => {
    // C25TAA -> C26TAA
    const prefix = current.substring(0, 1);
    const suffix = current.substring(3);
    return `${prefix}${year.toString().slice(-2)}${suffix}`;
  },
};

Quy trình Đặt lại Hàng năm

typescript
async function performAnnualReset(templateId: string): Promise<InvoiceTemplate> {
  const oldTemplate = await templateRepository.findById(templateId);
  const newYear = new Date().getFullYear();

  // Tạo mẫu mới cho năm mới
  const newTemplate = await templateRepository.create({
    merchantId: oldTemplate.merchantId,
    templateCode: oldTemplate.templateCode,
    templateSymbol: resetConfig.newTemplateSymbol(newYear, oldTemplate.templateSymbol),
    modelNumber: oldTemplate.modelNumber,
    templateName: oldTemplate.templateName,
    invoiceType: oldTemplate.invoiceType,
    currentNumber: 0,
    startNumber: 1,
    maxNumber: oldTemplate.maxNumber,
    resetAnnually: true,
    status: 'active',
    registeredAt: new Date(),
  });

  // Đánh dấu mẫu cũ là không hoạt động
  await templateRepository.updateStatus(templateId, 'inactive');

  // Khởi tạo bộ đếm Redis cho mẫu mới
  await redis.set(`invoice:number:${newTemplate.id}`, '0');

  return newTemplate;
}

Xử lý Khoảng trống

Bản ghi Số đã Hủy

typescript
interface CancelledNumber {
  id: string;
  templateId: string;
  invoiceNumber: string;
  number: number;
  cancelledAt: Date;
  reason: string;
  cancelledBy: string;
}

Báo cáo Khoảng trống

typescript
async function generateGapReport(templateId: string): Promise<GapReport> {
  const validation = await numberingService.validateSequence(templateId);
  const cancelledNumbers = await cancelledNumberRepository.findByTemplate(templateId);

  const unexplainedGaps = validation.gaps.filter(
    gap => !cancelledNumbers.some(cn => cn.number === gap),
  );

  return {
    templateId,
    totalGaps: validation.gaps.length,
    cancelledNumbers: cancelledNumbers.map(cn => cn.number),
    unexplainedGaps,
    requiresAction: unexplainedGaps.length > 0,
  };
}

Nhật ký Kiểm toán

Nhật ký Gán Số

typescript
interface NumberAssignmentLog {
  id: string;
  templateId: string;
  invoiceNumber: string;
  invoiceId: string;
  assignedAt: Date;
  assignedBy: string;
}

// Ghi nhật ký mỗi lần gán số
await numberAssignmentLogRepository.create({
  id: generateId(),
  templateId: template.id,
  invoiceNumber: fullNumber,
  invoiceId: invoice.id,
  assignedAt: new Date(),
  assignedBy: user.id,
});

Xử lý Lỗi

LỗiMô tả
TEMPLATE_NOT_FOUNDE001Mẫu không tồn tại
TEMPLATE_INACTIVEE002Mẫu không hoạt động
TEMPLATE_EXHAUSTEDE003Tất cả các số đã được sử dụng
INSUFFICIENT_NUMBERSE004Không đủ số cho phạm vi
DUPLICATE_NUMBERE005Số đã được gán
SEQUENCE_GAPE006Phát hiện khoảng trống trong chuỗi

Tài liệu Liên quan

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