# Task: Document Numbering Service **Status:** Not Started **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) สำหรับการสร้างเลขที่เอกสารอัตโนมัติ พร้อม comprehensive error handling, monitoring, และ audit logging --- ## 🎯 Objectives - ✅ Template-Based Number Generation (รองรับ 9 token types) - ✅ Double-Lock Protection (Redis + DB Optimistic Lock) - ✅ Concurrent-Safe (No duplicate numbers, tested with 100+ concurrent requests) - ✅ Support 4 Document Types (Correspondence, RFA, Transmittal, Drawing) - ✅ Year-Based Reset (พ.ศ. และ ค.ศ.) - ✅ Transmittal Special Logic (To Owner vs To Contractor) - ✅ 4 Error Scenarios with Fallback Strategies - ✅ Comprehensive Audit Logging - ✅ Monitoring & Alerting - ✅ Rate Limiting & Security --- ## 📝 Acceptance Criteria ### 1. Number Generation - ✅ Generate unique sequential numbers - ✅ Support all 9 token types: `{PROJECT}`, `{ORG}`, `{TYPE}`, `{SUB_TYPE}`, `{DISCIPLINE}`, `{CATEGORY}`, `{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 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 - ✅ Correspondence (Letter Type และ Other Types) - ✅ RFA with Discipline - ✅ Transmittal (To Owner vs To Contractor with different formats) - ✅ Drawing with Category ### 4. Error Handling - ✅ Scenario 1: Redis Unavailable → Fallback to DB lock - ✅ Scenario 2: Lock Timeout → Retry 5x with exponential backoff - ✅ Scenario 3: Version Conflict → Retry 2x - ✅ Scenario 4: DB Connection Error → Retry 3x ### 5. Audit & Monitoring - ✅ Audit log for every generated number - ✅ Track lock wait times, retry counts, errors - ✅ Metrics collection for monitoring dashboard - ✅ Alerting on failures >5% ### 6. Security - ✅ Rate limiting: 10 req/min per user, 50 req/min per IP - ✅ Authorization checks - ✅ IP address logging --- ## 🛠️ Implementation Steps ### Step 1: Database Entities #### 1.1 Document Number Config Entity ```typescript // File: backend/src/modules/document-numbering/entities/document-number-config.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; import { Project } from '../../project/entities/project.entity'; import { DocumentType } from '../../document-type/entities/document-type.entity'; @Entity('document_number_configs') export class DocumentNumberConfig { @PrimaryGeneratedColumn() id: number; @Column() project_id: number; @Column() doc_type_id: number; @Column({ default: 0, comment: 'ประเภทย่อย (nullable, use 0 for fallback)' }) sub_type_id: number; @Column({ default: 0, comment: 'สาขาวิชา (nullable, use 0 for fallback)' }) discipline_id: number; @Column({ length: 255, comment: 'e.g. {PROJECT}-{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}-{REV}' }) template: string; @Column({ type: 'text', nullable: true }) description: string; @Column({ default: 0, comment: 'For template versioning' }) version: number; @CreateDateColumn() created_at: Date; @UpdateDateColumn() updated_at: Date; @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project: Project; @ManyToOne(() => DocumentType) @JoinColumn({ name: 'doc_type_id' }) documentType: DocumentType; } ``` #### 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'; @Entity('document_number_counters') export class DocumentNumberCounter { @PrimaryColumn() project_id: number; @PrimaryColumn() doc_type_id: number; @PrimaryColumn({ default: 0 }) sub_type_id: number; // สำหรับ Correspondence types @PrimaryColumn({ default: 0 }) discipline_id: number; // สำหรับ RFA, Drawing @PrimaryColumn({ type: 'varchar', length: 20, nullable: true, default: null }) recipient_type: string; // สำหรับ Transmittal: 'OWNER', 'CONTRACTOR', 'CONSULTANT', 'OTHER' @PrimaryColumn() year: number; // ปี พ.ศ. หรือ ค.ศ. @Column({ default: 0 }) last_number: number; @VersionColumn({ comment: 'Optimistic Lock version' }) version: number; @UpdateDateColumn() updated_at: Date; } ``` #### 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 { DocumentNumberConfig } from './entities/document-number-config.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, @InjectRepository(DocumentNumberConfig) private configRepo: Repository, @InjectRepository(DocumentNumberAudit) private auditRepo: Repository, 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 { 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 { 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, subTypeId, disciplineId); const formattedNumber = await this.formatNumber(config.template, { 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 { 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, subTypeId, disciplineId); const formattedNumber = await this.formatNumber(config.template, { 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.template, 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 { const tokens = { '{PROJECT}': await this.getProjectCode(data.projectId), '{ORG}': await this.getOrgCode(data.organizationId), '{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( fn: () => Promise, maxRetries: number, initialDelay: number, ): Promise { 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 */ private async getConfig( projectId: number, docTypeId: number, subTypeId: number, disciplineId: number, ): Promise { // Try exact match first let config = await this.configRepo.findOne({ where: { project_id: projectId, doc_type_id: docTypeId, sub_type_id: subTypeId, discipline_id: disciplineId, }, }); // Fallback to default (subTypeId=0, disciplineId=0) if (!config) { config = await this.configRepo.findOne({ where: { project_id: projectId, doc_type_id: docTypeId, sub_type_id: 0, discipline_id: 0, }, }); } if (!config) { throw new NotFoundException( `Document number config not found for project=${projectId}, docType=${docTypeId}` ); } return config; } /** * Token helper methods */ private async getProjectCode(projectId: number): Promise { // TODO: Fetch from ProjectRepository return 'LCBP3'; } private async getOrgCode(organizationId: number): Promise { // TODO: Fetch from OrganizationRepository return 'C2'; } private async getTypeCode(docTypeId: number): Promise { // TODO: Fetch from DocumentTypeRepository return 'RFI'; } private async getSubTypeCode(subTypeId: number): Promise { if (subTypeId === 0) return ''; // TODO: Fetch from SubTypeRepository return '21'; } private async getDisciplineCode(disciplineId: number): Promise { if (disciplineId === 0) return 'GEN'; // TODO: Fetch from DisciplineRepository return 'STR'; } private async getCategoryCode(categoryId: number): Promise { if (!categoryId) return ''; // TODO: Fetch from CategoryRepository return 'DRW'; } private buildLockKey(...parts: Array): 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 { 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; beforeEach(async () => { const module = await Test.createTestingModule({ imports: [DocumentNumberingModule], }).compile(); service = module.get(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 |