12 KiB
12 KiB
Task: Document Numbering Service
Status: Not Started Priority: P1 (High - Critical for Documents) Estimated Effort: 5-6 days Dependencies: TASK-BE-001 (Database), TASK-BE-002 (Auth) Owner: Backend Team
📋 Overview
สร้าง DocumentNumberingService ที่ใช้ Double-Lock mechanism (Redis + DB Optimistic Lock) สำหรับการสร้างเลขที่เอกสารอัตโนมัติ
🎯 Objectives
- ✅ Template-Based Number Generation
- ✅ Double-Lock Protection (Redis + DB)
- ✅ Concurrent-Safe (No duplicate numbers)
- ✅ Support Disciplines
- ✅ Year-Based Reset
📝 Acceptance Criteria
-
Number Generation:
- ✅ Generate unique sequential numbers
- ✅ Support format:
{ORG}-{TYPE}-{DISCIPLINE}-{YEAR}-{SEQ:4} - ✅ No duplicates even with 100+ concurrent requests
- ✅ Generate within 100ms (p90)
-
Lock Mechanism:
- ✅ Redis lock acquired (TTL: 3 seconds)
- ✅ DB optimistic lock with version column
- ✅ Retry on conflict (3 times max)
- ✅ Exponential backoff
-
Format Templates:
- ✅ Configure per Project/Type
- ✅ Support all token types
- ✅ Validate format before use
🛠️ Implementation Steps
1. Entity - Document Number Format
// File: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
@Entity('document_number_formats')
export class DocumentNumberFormat {
@PrimaryGeneratedColumn()
id: number;
@Column()
project_id: number;
@Column()
correspondence_type_id: number;
@Column({ length: 255 })
format_template: string;
// Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}'
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType: CorrespondenceType;
}
2. Entity - Document Number Counter
// File: backend/src/modules/document-numbering/entities/document-number-counter.entity.ts
@Entity('document_number_counters')
export class DocumentNumberCounter {
@PrimaryColumn()
project_id: number;
@PrimaryColumn()
originator_organization_id: number;
@PrimaryColumn()
correspondence_type_id: number;
@PrimaryColumn({ default: 0 })
discipline_id: number;
@PrimaryColumn()
current_year: number;
@Column({ default: 0 })
last_number: number;
@VersionColumn() // Optimistic Lock
version: number;
@UpdateDateColumn()
updated_at: Date;
}
3. Numbering Service
// File: backend/src/modules/document-numbering/document-numbering.service.ts
import Redlock from 'redlock';
interface NumberingContext {
projectId: number;
organizationId: number;
typeId: number;
disciplineId?: number;
year?: number;
}
@Injectable()
export class DocumentNumberingService {
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
@InjectRepository(CorrespondenceType)
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(Discipline)
private disciplineRepo: Repository<Discipline>,
private redlock: Redlock,
private logger: Logger
) {}
async generateNextNumber(context: NumberingContext): Promise<string> {
const year = context.year || new Date().getFullYear();
const disciplineId = context.disciplineId || 0;
// Build Redis lock key
const lockKey = this.buildLockKey(
context.projectId,
context.organizationId,
context.typeId,
disciplineId,
year
);
// Retry logic with exponential backoff
return this.retryWithBackoff(
async () =>
await this.generateNumberWithLock(lockKey, context, year, disciplineId),
3,
200
);
}
private async generateNumberWithLock(
lockKey: string,
context: NumberingContext,
year: number,
disciplineId: number
): Promise<string> {
// Step 1: Acquire Redis lock
const lock = await this.redlock.acquire([lockKey], 3000); // 3 sec TTL
try {
// Step 2: Get or create counter
let counter = await this.counterRepo.findOne({
where: {
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
},
});
if (!counter) {
// Initialize new counter
counter = this.counterRepo.create({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
last_number: 0,
version: 0,
});
await this.counterRepo.save(counter);
}
const currentVersion = counter.version;
const nextNumber = counter.last_number + 1;
// Step 3: Update counter with Optimistic Lock
const result = await this.counterRepo
.createQueryBuilder()
.update(DocumentNumberCounter)
.set({
last_number: nextNumber,
})
.where({
project_id: context.projectId,
originator_organization_id: context.organizationId,
correspondence_type_id: context.typeId,
discipline_id: disciplineId,
current_year: year,
version: currentVersion, // Optimistic lock check
})
.execute();
if (result.affected === 0) {
throw new ConflictException('Counter version conflict - retrying...');
}
// Step 4: Format number
const formattedNumber = await this.formatNumber({
projectId: context.projectId,
typeId: context.typeId,
organizationId: context.organizationId,
disciplineId,
year,
sequenceNumber: nextNumber,
});
this.logger.log(`Generated number: ${formattedNumber}`);
return formattedNumber;
} finally {
// Step 5: Release lock
await lock.release();
}
}
private async formatNumber(data: any): Promise<string> {
// Get format template
const format = await this.formatRepo.findOne({
where: {
project_id: data.projectId,
correspondence_type_id: data.typeId,
},
});
if (!format) {
throw new NotFoundException('Document number format not found');
}
// Parse and replace tokens
let result = format.format_template;
const tokens = await this.buildTokens(data);
for (const [token, value] of Object.entries(tokens)) {
result = result.replace(token, value);
}
return result;
}
private async buildTokens(data: any): Promise<Record<string, string>> {
const org = await this.orgRepo.findOne({
where: { id: data.organizationId },
});
const type = await this.typeRepo.findOne({ where: { id: data.typeId } });
let discipline = null;
if (data.disciplineId > 0) {
discipline = await this.disciplineRepo.findOne({
where: { id: data.disciplineId },
});
}
return {
'{ORG_CODE}': org?.organization_code || 'ORG',
'{TYPE_CODE}': type?.type_code || 'TYPE',
'{DISCIPLINE_CODE}': discipline?.discipline_code || 'GEN',
'{YEAR}': data.year.toString(),
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
'{SEQ:5}': data.sequenceNumber.toString().padStart(5, '0'),
};
}
private buildLockKey(...parts: Array<number | string>): string {
return `doc_num:${parts.join(':')}`;
}
private async retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number,
initialDelay: number
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (!(error instanceof ConflictException) || attempt === maxRetries) {
throw error;
}
lastError = error;
const delay = initialDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
}
}
throw lastError;
}
}
4. Module
// File: backend/src/modules/document-numbering/document-numbering.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([
DocumentNumberCounter,
DocumentNumberFormat,
Organization,
CorrespondenceType,
Discipline,
]),
RedisModule,
],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService],
})
export class DocumentNumberingModule {}
✅ Testing & Verification
1. Concurrent Test
describe('DocumentNumberingService - Concurrency', () => {
it('should generate 100 unique numbers concurrently', async () => {
const context = {
projectId: 1,
organizationId: 3,
typeId: 1,
disciplineId: 2,
year: 2025,
};
const promises = Array(100)
.fill(null)
.map(() => service.generateNextNumber(context));
const results = await Promise.all(promises);
// Check uniqueness
const unique = new Set(results);
expect(unique.size).toBe(100);
// Check format
results.forEach((num) => {
expect(num).toMatch(/^TEAM-RFA-STR-2025-\d{4}$/);
});
});
it('should handle Redis lock timeout', async () => {
// Mock Redis lock to always timeout
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
await expect(service.generateNextNumber(context)).rejects.toThrow();
});
it('should retry on version conflict', async () => {
// Simulate conflict on first attempt
let attempt = 0;
jest.spyOn(counterRepo, 'createQueryBuilder').mockImplementation(() => {
attempt++;
return {
update: () => ({
set: () => ({
where: () => ({
execute: async () => ({
affected: attempt === 1 ? 0 : 1, // Fail first, succeed second
}),
}),
}),
}),
} as any;
});
const result = await service.generateNextNumber(context);
expect(result).toBeDefined();
expect(attempt).toBe(2);
});
});
2. Load Test
# artillery.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 30
arrivalRate: 20 # 20 req/sec
scenarios:
- name: 'Generate Document Numbers'
flow:
- post:
url: '/correspondences'
json:
title: 'Load Test {{ $randomString() }}'
project_id: 1
type_id: 1
discipline_id: 2
📚 Related Documents
📦 Deliverables
- DocumentNumberingService
- DocumentNumberCounter Entity
- DocumentNumberFormat Entity
- Format Template Parser
- Redis Lock Integration
- Retry Logic with Backoff
- Unit Tests (90% coverage)
- Concurrent Tests
- Load Tests
- Documentation
🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
|---|---|---|
| Redis lock failure | High | Retry + DB fallback |
| Version conflicts | Medium | Exponential backoff retry |
| Lock timeout | Medium | Increase TTL, optimize queries |
| Performance degradation | High | Redis caching, connection pooling |
📌 Notes
- Redis lock TTL: 3 seconds
- Max retries: 3
- Exponential backoff: 200ms → 400ms → 800ms
- Format template stored in database (configurable)
- Counters reset automatically per year