Main: revise specs to 1.5.0 (completed)
This commit is contained in:
476
specs/06-tasks/TASK-BE-004-document-numbering.md
Normal file
476
specs/06-tasks/TASK-BE-004-document-numbering.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user