Files
lcbp3/specs/06-tasks/TASK-BE-004-document-numbering.md
admin 863a727756
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
251208:1625 Frontend: to be complete admin panel, Backend: tobe recheck all task
2025-12-08 16:25:56 +07:00

1282 lines
39 KiB
Markdown

# Task: Document Numbering Service
**Status:** Ready for Implementation
**Priority:** P1 (High - Critical for Documents)
**Estimated Effort:** 7-8 days
**Dependencies:** TASK-BE-001 (Database), TASK-BE-002 (Auth), TASK-BE-003 (Redis Setup)
**Owner:** Backend Team
---
## 📋 Overview
สร้าง DocumentNumberingService ที่ใช้ Double-Lock mechanism (Redis + DB Optimistic Lock) สำหรับการสร้างเลขที่เอกสารอัตโนมัติ ตาม requirements ใน [03.11-document-numbering.md](file:///e:/np-dms/lcbp3/specs/01-requirements/03.11-document-numbering.md)
### เอกสารอ้างอิง
- **Requirements**: [03.11-document-numbering.md](file:///e:/np-dms/lcbp3/specs/01-requirements/03.11-document-numbering.md)
- **Implementation Guide**: [document-numbering.md](file:///e:/np-dms/lcbp3/specs/03-implementation/document-numbering.md)
- **Operations Guide**: [document-numbering-operations.md](file:///e:/np-dms/lcbp3/specs/04-operations/document-numbering-operations.md)
---
## 🎯 Objectives
- ✅ Template-Based Number Generation (รองรับ 10 token types)
- ✅ Double-Lock Protection (Redis Redlock + DB Optimistic Lock)
- ✅ Concurrent-Safe (No duplicate numbers, tested with 100+ concurrent requests)
- ✅ Support All Document Types (LETTER, RFA, TRANSMITTAL, RFI, MEMO, etc.)
- ✅ Year-Based Auto Reset (ปี ค.ศ.)
- ✅ 4 Error Scenarios with Fallback Strategies
- ✅ Comprehensive Audit Logging
- ✅ Monitoring & Alerting (Prometheus + Grafana)
- ✅ Rate Limiting & Security
---
## 📝 Acceptance Criteria
### 1. Number Generation
- ✅ Generate unique sequential numbers
- ✅ Support all 10 token types: `{PROJECT}`, `{ORIGINATOR}`, `{RECIPIENT}`, `{CORR_TYPE}`, `{SUB_TYPE}`, `{RFA_TYPE}`, `{DISCIPLINE}`, `{SEQ:n}`, `{YEAR:B.E.}`, `{YEAR:A.D.}`, `{REV}`
- ✅ No duplicates even with 100+ concurrent requests
- ✅ Performance: <500ms (normal), <2s (p95), <5s (p99)
### 2. Lock Mechanism
- ✅ Redis Redlock distributed lock (TTL: 5 seconds)
- ✅ DB optimistic lock with `version` column
- ✅ Fallback to DB pessimistic lock when Redis unavailable
- ✅ Retry with exponential backoff (5 retries max for lock, 2 for version conflict, 3 for DB errors)
### 3. Document Types Support & Scoping
-**General Correspondence** (LETTER / MEMO / etc.) → **Project Level Scope**
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)`
- *Note*: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
-**Transmittal****Project Level Scope** with Sub-Type lookup
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, sub_type_id, 0, 0, year)`
-**RFA****Contract Level Scope** (Implicit)
- Counter Key: `(project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, discipline_id, year)`
- *Mechanism*: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
### 4. Error Handling
- ✅ Scenario 1: Redis Unavailable → Fallback to DB pessimistic lock
- ✅ Scenario 2: Lock Timeout → Retry 5x with exponential backoff
- ✅ Scenario 3: Version Conflict → Retry 2x immediately
- ✅ Scenario 4: DB Connection Error → Retry 3x with exponential backoff
### 5. Audit & Monitoring
- ✅ Audit log for every generated number (with performance metrics)
- ✅ Error logging with classification (LOCK_TIMEOUT, VERSION_CONFLICT, etc.)
- ✅ Prometheus metrics collection
- ✅ Alerting on failures >5%
### 6. Security
- ✅ Rate limiting: 10 req/min per user, 50 req/min per IP (using @nestjs/throttler)
- ✅ Authorization checks (JWT + Roles)
- ✅ IP address logging
---
## 🛠️ Implementation Steps
### Step 1: Database Entities
// File: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Project } from '../../project/entities/project.entity';
import { CorrespondenceType } from '../../correspondence-type/entities/correspondence-type.entity';
@Entity('document_number_formats')
export class DocumentNumberFormat {
@PrimaryGeneratedColumn()
id: number;
@Column()
project_id: number;
@Column({ name: 'correspondence_type_id' })
correspondenceTypeId: number;
// Note: Schema currently only has project_id + correspondence_type_id.
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
formatTemplate: string;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType: CorrespondenceType;
}
#### 1.2 Document Number Counter Entity
```typescript
// File: backend/src/modules/document-numbering/entities/document-number-counter.entity.ts
import { Entity, PrimaryColumn, Column, UpdateDateColumn, VersionColumn } from 'typeorm';
/**
* ตาราง document_number_counters
* Composite PK: (project_id, originator_organization_id, recipient_organization_id,
* correspondence_type_id, sub_type_id, rfa_type_id, discipline_id, current_year)
*
* References: specs/01-requirements/03.11-document-numbering.md#counter-key-components
*/
@Entity('document_number_counters')
export class DocumentNumberCounter {
@PrimaryColumn({ name: 'project_id' })
projectId: number;
@PrimaryColumn({ name: 'originator_organization_id' })
originatorOrganizationId: number;
@PrimaryColumn({ name: 'recipient_organization_id', default: -1 })
recipientOrganizationId: number; // -1 if NULL (standardized for composite key)
@PrimaryColumn({ name: 'correspondence_type_id' })
correspondenceTypeId: number;
@PrimaryColumn({ name: 'sub_type_id', default: 0 })
subTypeId: number; // for TRANSMITTAL only
@PrimaryColumn({ name: 'rfa_type_id', default: 0 })
rfaTypeId: number; // for RFA only
@PrimaryColumn({ name: 'discipline_id', default: 0 })
disciplineId: number; // for RFA only
@PrimaryColumn({ name: 'current_year' })
currentYear: number; // ปี ค.ศ.
@Column({ name: 'last_number', default: 0 })
lastNumber: number;
@VersionColumn({ name: 'version', comment: 'Optimistic Lock version' })
version: number;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
```
> **⚠️ หมายเหตุ Schema:**
>
> - Primary Key ใช้ `recipient_organization_id = -1` แทน NULL (ตาม Schema v1.5.1)
> - `sub_type_id`, `rfa_type_id`, `discipline_id` ใช้ `0` แทน NULL
> - Counter reset อัตโนมัติทุกปี (แยก counter ตาม `current_year`)
#### 1.3 Document Number Audit Entity
```typescript
// File: backend/src/modules/document-numbering/entities/document-number-audit.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import { User } from '../../user/entities/user.entity';
@Entity('document_number_audit')
export class DocumentNumberAudit {
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
id: string;
@Column({ nullable: true, comment: 'FK to documents (set after doc creation)' })
document_id: number;
@Column({ length: 255 })
@Index('idx_audit_number')
generated_number: string;
@Column({ length: 500, comment: 'Redis lock key used' })
counter_key: string;
@Column({ length: 255 })
template_used: string;
@Column()
sequence_number: number;
@Column()
user_id: number;
@Column({ length: 45, nullable: true })
ip_address: string;
@Column({ default: 0 })
retry_count: number;
@Column({ default: 0, comment: 'Time spent waiting for lock in ms' })
lock_wait_ms: number;
@CreateDateColumn()
@Index('idx_audit_created')
created_at: Date;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}
```
---
### Step 2: DTOs
#### 2.1 Generate Number Request DTO
```typescript
// File: backend/src/modules/document-numbering/dto/generate-number.dto.ts
import { IsInt, IsOptional, IsEnum, IsIP, Min } from 'class-validator';
export enum RecipientType {
OWNER = 'OWNER',
CONTRACTOR = 'CONTRACTOR',
CONSULTANT = 'CONSULTANT',
OTHER = 'OTHER',
}
export class GenerateNumberDto {
@IsInt()
@Min(1)
projectId: number;
@IsInt()
@Min(1)
docTypeId: number;
@IsInt()
@IsOptional()
subTypeId?: number;
@IsInt()
@IsOptional()
disciplineId?: number;
@IsEnum(RecipientType)
@IsOptional()
recipientType?: RecipientType;
@IsInt()
@IsOptional()
year?: number;
@IsInt()
@Min(1)
userId: number;
@IsIP()
ipAddress: string;
}
```
---
### Step 3: Core Service Implementation
```typescript
// File: backend/src/modules/document-numbering/document-numbering.service.ts
import { Injectable, Logger, ConflictException, ServiceUnavailableException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import Redlock from 'redlock';
import Redis from 'ioredis';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberFormat } from './entities/document-number-format.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
import { GenerateNumberDto } from './dto/generate-number.dto';
import { MetricsService } from '../metrics/metrics.service';
@Injectable()
export class DocumentNumberingService {
private readonly logger = new Logger(DocumentNumberingService.name);
constructor(
@InjectRepository(DocumentNumberCounter)
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
@InjectRepository(DocumentNumberAudit)
private auditRepo: Repository<DocumentNumberAudit>,
private dataSource: DataSource,
private redis: Redis,
private redlock: Redlock,
private metricsService: MetricsService,
) {}
/**
* สร้างเลขที่เอกสารใหม่
* รองรับ 4 error scenarios:
* 1. Redis unavailable → Fallback to DB lock
* 2. Lock timeout → Retry 5x with exponential backoff
* 3. Version conflict → Retry 2x
* 4. DB connection error → Retry 3x
*/
async generateNextNumber(dto: GenerateNumberDto): Promise<string> {
const startTime = Date.now();
const year = dto.year || new Date().getFullYear() + 543; // พ.ศ. by default
const subTypeId = dto.subTypeId || 0; // Fallback for NULL
const disciplineId = dto.disciplineId || 0; // Fallback for NULL
const lockKey = this.buildLockKey(
dto.projectId,
dto.docTypeId,
subTypeId,
disciplineId,
dto.recipientType,
year,
);
try {
// Retry with exponential backoff for Scenarios 2, 3, 4
const result = await this.retryWithBackoff(
async () => await this.generateNumberWithLock(
lockKey,
dto,
year,
subTypeId,
disciplineId,
startTime,
),
5, // Max 5 retries for lock acquisition
1000, // Initial delay 1s
);
// Track metrics
const duration = Date.now() - startTime;
await this.metricsService.recordDuration('docnum.generate', duration);
await this.metricsService.incrementCounter('docnum.success');
return result;
} catch (error) {
const duration = Date.now() - startTime;
await this.metricsService.recordDuration('docnum.generate', duration);
await this.metricsService.incrementCounter('docnum.error', {
type: error.constructor.name,
});
throw error;
}
}
/**
* สร้างเลขที่พร้อม Redis lock
* Scenario 1: ถ้า Redis unavailable จะ fallback ไป DB lock
*/
private async generateNumberWithLock(
lockKey: string,
dto: GenerateNumberDto,
year: number,
subTypeId: number,
disciplineId: number,
startTime: number,
): Promise<string> {
let lock: any;
const lockStartTime = Date.now();
try {
// Step 1: Acquire Redis Distributed Lock
try {
lock = await this.redlock.acquire([lockKey], 5000); // 5 sec TTL
await this.metricsService.incrementCounter('docnum.lock.redis.success');
} catch (redisError) {
// Scenario 1: Redis Unavailable - Fallback to DB lock
this.logger.warn(`Redis lock failed, falling back to DB lock: ${redisError.message}`);
await this.metricsService.incrementCounter('docnum.lock.redis.failed');
return await this.generateWithDatabaseLock(dto, year, subTypeId, disciplineId);
}
const lockWaitMs = Date.now() - lockStartTime;
await this.metricsService.recordDuration('docnum.lock.wait', lockWaitMs);
// Step 2: Get or create counter
let counter = await this.counterRepo.findOne({
where: {
project_id: dto.projectId,
doc_type_id: dto.docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
recipient_type: dto.recipientType || null,
year: year,
},
});
if (!counter) {
counter = this.counterRepo.create({
project_id: dto.projectId,
doc_type_id: dto.docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
recipient_type: dto.recipientType || null,
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 (Scenario 3 handling)
const result = await this.counterRepo
.createQueryBuilder()
.update(DocumentNumberCounter)
.set({
last_number: nextNumber,
version: () => 'version + 1',
})
.where({
project_id: dto.projectId,
doc_type_id: dto.docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
recipient_type: dto.recipientType || null,
year: year,
version: currentVersion, // Optimistic lock check
})
.execute();
if (result.affected === 0) {
// Scenario 3: Version Conflict
await this.metricsService.incrementCounter('docnum.conflict.version');
throw new ConflictException('Counter version conflict - retrying...');
}
// Step 4: Get config and format number
const config = await this.getConfig(dto.projectId, dto.docTypeId);
const formattedNumber = await this.formatNumber(config.formatTemplate, {
projectId: dto.projectId,
docTypeId: dto.docTypeId,
subTypeId,
disciplineId,
year,
sequenceNumber: nextNumber,
recipientType: dto.recipientType,
});
// Step 5: Audit logging
await this.auditRepo.save({
document_id: null, // Will be updated after document creation
generated_number: formattedNumber,
counter_key: lockKey,
template_used: config.template,
sequence_number: nextNumber,
user_id: dto.userId,
ip_address: dto.ipAddress,
retry_count: 0,
lock_wait_ms: lockWaitMs,
});
this.logger.log(`Generated: ${formattedNumber} (lock wait: ${lockWaitMs}ms, total: ${Date.now() - startTime}ms)`);
return formattedNumber;
} finally {
// Step 6: Release Redis lock
if (lock) {
try {
await lock.release();
} catch (error) {
this.logger.warn(`Failed to release lock: ${error.message}`);
}
}
}
}
/**
* Scenario 1: Fallback to Database Pessimistic Lock
*/
private async generateWithDatabaseLock(
dto: GenerateNumberDto,
year: number,
subTypeId: number,
disciplineId: number,
): Promise<string> {
return await this.dataSource.transaction(async (manager) => {
// Pessimistic lock: SELECT ... FOR UPDATE
const counter = await manager
.createQueryBuilder(DocumentNumberCounter, 'counter')
.setLock('pessimistic_write')
.where({
project_id: dto.projectId,
doc_type_id: dto.docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
recipient_type: dto.recipientType || null,
year: year,
})
.getOne();
const nextNumber = (counter?.last_number || 0) + 1;
// Update or create counter
if (counter) {
await manager.update(DocumentNumberCounter, {
project_id: dto.projectId,
doc_type_id: dto.docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
recipient_type: dto.recipientType || null,
year: year,
}, {
last_number: nextNumber,
});
} else {
await manager.save(DocumentNumberCounter, {
project_id: dto.projectId,
doc_type_id: dto.docTypeId,
sub_type_id: subTypeId,
discipline_id: disciplineId,
recipient_type: dto.recipientType || null,
year: year,
last_number: nextNumber,
version: 0,
});
}
// Format number
const config = await this.getConfig(dto.projectId, dto.docTypeId);
const formattedNumber = await this.formatNumber(config.formatTemplate, {
projectId: dto.projectId,
docTypeId: dto.docTypeId,
subTypeId,
disciplineId,
year,
sequenceNumber: nextNumber,
recipientType: dto.recipientType,
});
// Audit log
await manager.save(DocumentNumberAudit, {
generated_number: formattedNumber,
counter_key: `db_lock:${dto.projectId}:${dto.docTypeId}`,
template_used: config.formatTemplate,
sequence_number: nextNumber,
user_id: dto.userId,
ip_address: dto.ipAddress,
retry_count: 0,
lock_wait_ms: 0,
});
await this.metricsService.incrementCounter('docnum.lock.db.fallback');
return formattedNumber;
});
}
/**
* Format number ด้วย template และ token replacement
* รองรับ token ทั้งหมด 9 ประเภท
*/
private async formatNumber(template: string, data: any): Promise<string> {
const tokens = {
'{PROJECT}': await this.getProjectCode(data.projectId),
'{ORIGINATOR}': await this.getOriginatorOrgCode(data.originatorOrganizationId),
'{RECIPIENT}': await this.getRecipientOrgCode(data.recipientOrganizationId),
'{CORR_TYPE}': await this.getTypeCode(data.docTypeId),
'{SUB_TYPE}': await this.getSubTypeCode(data.subTypeId),
'{DISCIPLINE}': await this.getDisciplineCode(data.disciplineId),
'{CATEGORY}': await this.getCategoryCode(data.categoryId),
'{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
'{SEQ:5}': data.sequenceNumber.toString().padStart(5, '0'),
'{YEAR:B.E.}': data.year.toString(),
'{YEAR:A.D.}': (data.year - 543).toString(),
'{REV}': data.revisionCode || 'A',
};
let result = template;
for (const [token, value] of Object.entries(tokens)) {
result = result.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value);
}
return result;
}
/**
* Retry with exponential backoff
* Scenarios 2, 3, 4
*/
private async retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number,
initialDelay: number,
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryable =
error instanceof ConflictException || // Scenario 3
error.code === 'ECONNREFUSED' || // Scenario 4
error.code === 'ETIMEDOUT' || // Scenario 4
error.message?.includes('Lock timeout'); // Scenario 2
if (!isRetryable || attempt === maxRetries) {
if (attempt === maxRetries) {
// Scenario 2: Max retries reached
throw new ServiceUnavailableException(
'ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง'
);
}
throw 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 (${error.message})`
);
await this.metricsService.incrementCounter('docnum.retry', {
attempt: attempt + 1,
reason: error.constructor.name,
});
}
}
throw new InternalServerErrorException('Unexpected retry loop exit');
}
/**
* Get configuration template (Format)
*/
private async getConfig(
projectId: number,
correspondenceTypeId: number,
): Promise<DocumentNumberFormat> {
// Note: Schema currently only separates by project_id and correspondence_type_id
// If we need sub-type specific templates, we should check if they are supported in the future schema.
// Converting old logic slightly to match v1.5.1 schema columns.
const config = await this.formatRepo.findOne({
where: {
project_id: projectId,
correspondenceTypeId: correspondenceTypeId,
},
});
if (!config) {
throw new NotFoundException(
`Document number format not found for project=${projectId}, type=${correspondenceTypeId}`
);
}
return config;
}
/**
* Token helper methods
*/
private async getProjectCode(projectId: number): Promise<string> {
// TODO: Fetch from ProjectRepository
return 'LCBP3';
}
private async getOrgCode(organizationId: number): Promise<string> {
// TODO: Fetch from OrganizationRepository
return 'C2';
}
private async getTypeCode(docTypeId: number): Promise<string> {
// TODO: Fetch from DocumentTypeRepository
return 'RFI';
}
private async getSubTypeCode(subTypeId: number): Promise<string> {
if (subTypeId === 0) return '';
// TODO: Fetch from SubTypeRepository
return '21';
}
private async getDisciplineCode(disciplineId: number): Promise<string> {
if (disciplineId === 0) return 'GEN';
// TODO: Fetch from DisciplineRepository
return 'STR';
}
private async getCategoryCode(categoryId: number): Promise<string> {
if (!categoryId) return '';
// TODO: Fetch from CategoryRepository
return 'DRW';
}
private buildLockKey(...parts: Array<number | string | null | undefined>): string {
return `doc_num:${parts.filter(p => p !== null && p !== undefined).join(':')}`;
}
}
```
---
### Step 4: Controller with Rate Limiting
```typescript
// File: backend/src/modules/document-numbering/document-numbering.controller.ts
import { Controller, Post, Get, Put, Body, Param, UseGuards, Req, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RateLimitGuard } from '../common/guards/rate-limit.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { DocumentNumberingService } from './document-numbering.service';
import { GenerateNumberDto } from './dto/generate-number.dto';
import { Request } from 'express';
@ApiTags('Document Numbering')
@Controller('api/v1/document-numbering')
@UseGuards(JwtAuthGuard, RateLimitGuard)
@ApiBearerAuth()
export class DocumentNumberingController {
constructor(private readonly numberingService: DocumentNumberingService) {}
@Post('generate')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Generate document number' })
@ApiResponse({ status: 201, description: 'Number generated successfully' })
@ApiResponse({ status: 409, description: 'Version conflict' })
@ApiResponse({ status: 503, description: 'Service unavailable' })
async generateNumber(
@Body() dto: GenerateNumberDto,
@Req() req: Request,
): Promise<{ documentNumber: string }> {
// Add user context from JWT
const user = req.user as any;
dto.userId = user.id;
dto.ipAddress = req.ip;
const documentNumber = await this.numberingService.generateNextNumber(dto);
return { documentNumber };
}
@Get('configs')
@ApiOperation({ summary: 'List all numbering configurations' })
@Roles('admin', 'project_admin')
@UseGuards(RolesGuard)
async listConfigs() {
// TODO: Implement
return { message: 'List configs' };
}
@Put('configs/:id')
@ApiOperation({ summary: 'Update numbering configuration' })
@Roles('project_admin')
@UseGuards(RolesGuard)
async updateConfig(@Param('id') id: number, @Body() updateDto: any) {
// TODO: Implement with template validation
return { message: 'Update config' };
}
@Post('configs/:id/reset-counter')
@ApiOperation({ summary: 'Reset counter (Super Admin only)' })
@Roles('super_admin')
@UseGuards(RolesGuard)
async resetCounter(@Param('id') id: number) {
// TODO: Implement with audit logging
return { message: 'Reset counter' };
}
}
```
---
### Step 5: Rate Limiting Guard
```typescript
// File: backend/src/modules/common/guards/rate-limit.guard.ts
import { Injectable, CanActivate, ExecutionContext, HttpException, HttpStatus } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import Redis from 'ioredis';
@Injectable()
export class RateLimitGuard implements CanActivate {
constructor(
private redis: Redis,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;
const ip = request.ip;
// Rate limit per user: 10 requests/minute
if (user) {
const userKey = `rate_limit:user:${user.id}`;
const userCount = await this.redis.incr(userKey);
if (userCount === 1) {
await this.redis.expire(userKey, 60); // 1 minute
}
if (userCount > 10) {
throw new HttpException(
'Rate limit exceeded: 10 requests per minute per user',
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
// Rate limit per IP: 50 requests/minute
const ipKey = `rate_limit:ip:${ip}`;
const ipCount = await this.redis.incr(ipKey);
if (ipCount === 1) {
await this.redis.expire(ipKey, 60);
}
if (ipCount > 50) {
throw new HttpException(
'Rate limit exceeded: 50 requests per minute per IP',
HttpStatus.TOO_MANY_REQUESTS,
);
}
return true;
}
}
```
---
### Step 6: Module
```typescript
// File: backend/src/modules/document-numbering/document-numbering.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DocumentNumberingService } from './document-numbering.service';
import { DocumentNumberingController } from './document-numbering.controller';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberConfig } from './entities/document-number-config.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity';
import { RedisModule } from '../redis/redis.module';
import { MetricsModule } from '../metrics/metrics.module';
@Module({
imports: [
TypeOrmModule.forFeature([
DocumentNumberCounter,
DocumentNumberConfig,
DocumentNumberAudit,
]),
RedisModule,
MetricsModule,
],
controllers: [DocumentNumberingController],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService],
})
export class DocumentNumberingModule {}
```
---
## ✅ Testing & Verification
### Test 1: Concurrent Number Generation
```typescript
// File: backend/test/document-numbering/concurrent.spec.ts
describe('DocumentNumberingService - Concurrency', () => {
let service: DocumentNumberingService;
let counterRepo: Repository<DocumentNumberCounter>;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [DocumentNumberingModule],
}).compile();
service = module.get<DocumentNumberingService>(DocumentNumberingService);
counterRepo = module.get(getRepositoryToken(DocumentNumberCounter));
});
it('should generate 100 unique numbers concurrently', async () => {
const dto: GenerateNumberDto = {
projectId: 1,
docTypeId: 2, // RFA
disciplineId: 3, // STR
year: 2568,
userId: 1,
ipAddress: '192.168.1.1',
};
const promises = Array(100)
.fill(null)
.map(() => service.generateNextNumber(dto));
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(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
});
// Verify counter in DB
const counter = await counterRepo.findOne({
where: {
project_id: 1,
doc_type_id: 2,
discipline_id: 3,
year: 2568,
},
});
expect(counter.last_number).toBe(100);
});
});
```
### Test 2: Error Scenarios
```typescript
// File: backend/test/document-numbering/error-scenarios.spec.ts
describe('DocumentNumberingService - Error Scenarios', () => {
it('Scenario 1: Should fallback to DB lock when Redis unavailable', async () => {
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Redis connection error'));
const result = await service.generateNextNumber(dto);
expect(result).toBeDefined();
expect(result).toMatch(/^LCBP3-/);
expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('falling back to DB lock'));
expect(metricsService.incrementCounter).toHaveBeenCalledWith('docnum.lock.db.fallback');
});
it('Scenario 2: Should retry on lock timeout and throw 503 after max retries', async () => {
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
await expect(service.generateNextNumber(dto))
.rejects
.toThrow(ServiceUnavailableException);
expect(metricsService.incrementCounter).toHaveBeenCalledWith('docnum.retry', expect.any(Object));
});
it('Scenario 3: Should retry on version conflict and succeed', async () => {
let attempt = 0;
jest.spyOn(counterRepo, 'createQueryBuilder').mockImplementation(() => {
attempt++;
return {
update: () => ({
set: () => ({
where: () => ({
execute: async () => ({
affected: attempt === 1 ? 0 : 1,
}),
}),
}),
}),
} as any;
});
const result = await service.generateNextNumber(dto);
expect(result).toBeDefined();
expect(attempt).toBe(2);
expect(metricsService.incrementCounter).toHaveBeenCalledWith('docnum.conflict.version');
});
it('Scenario 4: Should retry on DB connection error', async () => {
let attempt = 0;
jest.spyOn(counterRepo, 'findOne').mockImplementation(async () => {
attempt++;
if (attempt === 1) {
const error: any = new Error('DB connection timeout');
error.code = 'ETIMEDOUT';
throw error;
}
return mockCounter;
});
const result = await service.generateNextNumber(dto);
expect(result).toBeDefined();
expect(attempt).toBe(2);
});
});
```
### Test 3: Document Type Formats
```typescript
// File: backend/test/document-numbering/formats.spec.ts
describe('DocumentNumberingService - Formats', () => {
it('should format Correspondence correctly', async () => {
const result = await service.generateNextNumber({
projectId: 1,
docTypeId: 1, // Correspondence
subTypeId: 3, // Letter
year: 2568,
userId: 1,
ipAddress: '192.168.1.1',
});
expect(result).toMatch(/^คคง\.-สคฉ\.3-0985-2568$/);
});
it('should format Transmittal To Owner correctly', async () => {
const result = await service.generateNextNumber({
projectId: 1,
docTypeId: 3, // Transmittal
recipientType: 'OWNER',
year: 2568,
userId: 1,
ipAddress: '192.168.1.1',
});
expect(result).toMatch(/^คคง\.-สคฉ\.3-03-21-\d{4}-2568$/);
});
it('should format RFA correctly', async () => {
const result = await service.generateNextNumber({
projectId: 1,
docTypeId: 2, // RFA
disciplineId: 3, // STR
year: 2568,
userId: 1,
ipAddress: '192.168.1.1',
});
expect(result).toMatch(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
});
it('should format Drawing correctly', async () => {
const result = await service.generateNextNumber({
projectId: 1,
docTypeId: 4, // Drawing
disciplineId: 3, // STR
categoryId: 1, // DRW
year: 2568,
userId: 1,
ipAddress: '192.168.1.1',
});
expect(result).toMatch(/^LCBP3-STR-DRW-\d{4}-A$/);
});
});
```
### Test 4: Rate Limiting
```typescript
// File: backend/test/document-numbering/rate-limit.spec.ts
describe('RateLimitGuard', () => {
it('should block after 10 requests per user per minute', async () => {
const dto = { /* ... */ };
// Make 10 successful requests
for (let i = 0; i < 10; i++) {
await service.generateNextNumber(dto);
}
// 11th request should fail
await expect(service.generateNextNumber(dto))
.rejects
.toThrow('Rate limit exceeded');
});
it('should block after 50 requests per IP per minute', async () => {
// Test similar to above but with different users from same IP
// ...
});
});
```
### Load Test
```yaml
# File: artillery.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 60
arrivalRate: 50
name: 'Normal Load (50 req/sec)'
- duration: 30
arrivalRate: 100
name: 'Peak Load (100 req/sec)'
scenarios:
- name: 'Generate Document Numbers - RFA'
weight: 40
flow:
- post:
url: '/api/v1/rfa'
headers:
Authorization: 'Bearer {{ $processEnvironment.TEST_JWT }}'
json:
title: 'Load Test RFA {{ $randomString() }}'
project_id: 1
doc_type_id: 2
discipline_id: 3
- name: 'Generate Document Numbers - Transmittal'
weight: 30
flow:
- post:
url: '/api/v1/transmittals'
headers:
Authorization: 'Bearer {{ $processEnvironment.TEST_JWT }}'
json:
title: 'Load Test Transmittal {{ $randomString() }}'
project_id: 1
doc_type_id: 3
recipient_type: 'OWNER'
- name: 'Generate Document Numbers - Correspondence'
weight: 30
flow:
- post:
url: '/api/v1/correspondences'
headers:
Authorization: 'Bearer {{ $processEnvironment.TEST_JWT }}'
json:
title: 'Load Test Correspondence {{ $randomString() }}'
project_id: 1
doc_type_id: 1
expect:
- statusCode: [200, 201]
- contentType: json
ensure:
p50: 500 # 50th percentile < 500ms
p95: 2000 # 95th percentile < 2s
p99: 5000 # 99th percentile < 5s
maxErrorRate: 0.001 # < 0.1% errors
```
---
## 📚 Related Documents
- [ADR-002: Document Numbering Strategy](../05-decisions/ADR-002-document-numbering-strategy.md)
- [Requirements 3.11: Document Numbering](../01-requirements/03.11-document-numbering.md)
- [Backend Guidelines - Document Numbering](../03-implementation/backend-guidelines.md#document-numbering)
- [Data Dictionary](../../docs/4_Data_Dictionary_V1_4_4.md)
---
## 📦 Deliverables
### Core Implementation
- [x] DocumentNumberingService with all 4 error scenarios
- [x] DocumentNumberCounter Entity (with sub_type_id, recipient_type)
- [x] DocumentNumberConfig Entity
- [x] DocumentNumberAudit Entity
- [x] Format Template Parser (9 token types)
- [x] Redis Lock Integration with Fallback
- [x] Retry Logic with Exponential Backoff
### API & Security
- [x] DocumentNumberingController with 4 endpoints
- [x] Rate Limiting Guard (10/min per user, 50/min per IP)
- [x] Authorization Guards
- [x] API Documentation (Swagger)
### Testing
- [x] Unit Tests (targeting 90%+ coverage)
- [x] Concurrent Tests (100+ simultaneous requests)
- [x] Error Scenario Tests (all 4 scenarios)
- [x] Format Tests (all 4 document types)
- [x] Rate Limiting Tests
- [x] Load Tests (Artillery config for 50-100 req/sec)
### Monitoring & Documentation
- [x] Metrics Collection Integration
- [x] Audit Logging
- [x] Implementation Documentation
- [x] API Documentation
---
## 🚨 Risks & Mitigation
| Risk | Impact | Probability | Mitigation |
| --------------------------------- | ------ | ----------- | --------------------------------------------- |
| Redis lock failure | High | Low | Automatic fallback to DB lock |
| Version conflicts under high load | Medium | Medium | Exponential backoff retry (2x) |
| Lock timeout | Medium | Low | Retry 5x with exponential backoff |
| Performance degradation | High | Medium | Redis caching, connection pooling, monitoring |
| DB connection pool exhaustion | High | Low | Retry 3x, increase pool size, monitoring |
| Rate limit bypass | Medium | Low | Multi-layer limiting (user + IP + global) |
---
## 📌 Implementation Notes
### Performance Targets
- **Normal Operation:** <500ms (no conflicts, Redis available)
- **95th Percentile:** <2 seconds (including retries)
- **99th Percentile:** <5 seconds (worst case scenarios)
### Lock Configuration
- **Redis Lock TTL:** 5 seconds (auto-release)
- **Lock Acquisition Timeout:** 10 seconds
- **Max Retries (Lock):** 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s)
- **Max Retries (Version):** 2 times
- **Max Retries (DB Error):** 3 times with exponential backoff (1s, 2s, 4s)
### Rate Limiting
- **Per User:** 10 requests/minute
- **Per IP:** 50 requests/minute
- **Global:** 5000 requests/minute
### Format Templates
Stored in database (`document_number_configs` table), configurable per:
- Project
- Document Type
- Sub Type (optional, use 0 for fallback)
- Discipline (optional, use 0 for fallback)
### Counter Reset
- Automatic reset per year (based on `{YEAR:B.E.}` or `{YEAR:A.D.}` in template)
- Manual reset available (Super Admin only, with audit log)
---
## 🔄 Version History
| Version | Date | Changes |
| ------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.0 | 2025-11-30 | Initial task definition |
| 2.0 | 2025-12-02 | Comprehensive update with all 9 tokens, 4 document types, 4 error scenarios, audit logging, monitoring, rate limiting, and complete implementation details |