Đá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ắc | Mô tả |
|---|---|
| Tuần tự | Số phải liên tiếp (1, 2, 3...) |
| Không Khoảng trống | Khoảng trống yêu cầu bản ghi hủy |
| Theo Mẫu | Chuỗi riêng biệt cho mỗi mẫu hóa đơn |
| Đệm số 0 | Số có 8 chữ số với các số 0 đứng đầu |
| Đặt lại | Có 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ênCấ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ỗi | Mã | Mô tả |
|---|---|---|
TEMPLATE_NOT_FOUND | E001 | Mẫu không tồn tại |
TEMPLATE_INACTIVE | E002 | Mẫu không hoạt động |
TEMPLATE_EXHAUSTED | E003 | Tất cả các số đã được sử dụng |
INSUFFICIENT_NUMBERS | E004 | Không đủ số cho phạm vi |
DUPLICATE_NUMBER | E005 | Số đã được gán |
SEQUENCE_GAP | E006 | Phát hiện khoảng trống trong chuỗi |