Files
lcbp3/specs/06-tasks/TASK-BE-004-document-numbering.md
admin d74218bb2a
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
251202:2300 Prepare 1.5.1
2025-12-03 01:16:27 +07:00

38 KiB

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) สำหรับการสร้างเลขที่เอกสารอัตโนมัติ ตาม requirements ใน 03.11-document-numbering.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

  • LETTER / RFI / MEMO / EMAIL / MOM / INSTRUCTION / NOTICE / OTHER
    • Counter Key: (project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)
  • TRANSMITTAL
    • Counter Key: (project_id, originator_org_id, recipient_org_id, corr_type_id, sub_type_id, 0, 0, year)
  • RFA
    • Counter Key: (project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, discipline_id, year)

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

1.1 Document Number Config Entity

// 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

// 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', nullable: true })
  recipientOrganizationId: number | null;  // NULL for RFA

  @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 ใช้ COALESCE(recipient_organization_id, 0) ในการสร้าง constraint (ดู migration file)
  • sub_type_id, rfa_type_id, discipline_id ใช้ 0 แทน NULL
  • Counter reset อัตโนมัติทุกปี (แยก counter ตาม current_year)

1.3 Document Number Audit Entity

// 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

// 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

// 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<DocumentNumberCounter>,
    @InjectRepository(DocumentNumberConfig)
    private configRepo: Repository<DocumentNumberConfig>,
    @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, 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<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, 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<string> {
    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<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
   */
  private async getConfig(
    projectId: number,
    docTypeId: number,
    subTypeId: number,
    disciplineId: number,
  ): Promise<DocumentNumberConfig> {
    // 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<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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

# 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


📦 Deliverables

Core Implementation

  • DocumentNumberingService with all 4 error scenarios
  • DocumentNumberCounter Entity (with sub_type_id, recipient_type)
  • DocumentNumberConfig Entity
  • DocumentNumberAudit Entity
  • Format Template Parser (9 token types)
  • Redis Lock Integration with Fallback
  • Retry Logic with Exponential Backoff

API & Security

  • DocumentNumberingController with 4 endpoints
  • Rate Limiting Guard (10/min per user, 50/min per IP)
  • Authorization Guards
  • API Documentation (Swagger)

Testing

  • Unit Tests (targeting 90%+ coverage)
  • Concurrent Tests (100+ simultaneous requests)
  • Error Scenario Tests (all 4 scenarios)
  • Format Tests (all 4 document types)
  • Rate Limiting Tests
  • Load Tests (Artillery config for 50-100 req/sec)

Monitoring & Documentation

  • Metrics Collection Integration
  • Audit Logging
  • Implementation Documentation
  • 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