Files
lcbp3/specs/06-tasks/TASK-BE-004-document-numbering.md

477 lines
12 KiB
Markdown

# 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
1. **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)
2. **Lock Mechanism:**
- ✅ Redis lock acquired (TTL: 3 seconds)
- ✅ DB optimistic lock with version column
- ✅ Retry on conflict (3 times max)
- ✅ Exponential backoff
3. **Format Templates:**
- ✅ Configure per Project/Type
- ✅ Support all token types
- ✅ Validate format before use
---
## 🛠️ Implementation Steps
### 1. Entity - Document Number Format
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
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
```yaml
# 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
- [ADR-002: Document Numbering Strategy](../05-decisions/ADR-002-document-numbering-strategy.md)
- [Backend Guidelines - Document Numbering](../03-implementation/backend-guidelines.md#document-numbering)
---
## 📦 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