Files
lcbp3/specs/06-tasks/TASK-BE-004-document-numbering.md
admin 047e1b88ce
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
Main: revise specs to 1.5.0 (completed)
2025-12-01 01:28:32 +07:00

12 KiB

Task: Document Numbering Service

Status: Not Started Priority: P1 (High - Critical for Documents) Estimated Effort: 5-6 days Dependencies: TASK-BE-001 (Database), TASK-BE-002 (Auth) Owner: Backend Team


📋 Overview

สร้าง DocumentNumberingService ที่ใช้ Double-Lock mechanism (Redis + DB Optimistic Lock) สำหรับการสร้างเลขที่เอกสารอัตโนมัติ


🎯 Objectives

  • Template-Based Number Generation
  • Double-Lock Protection (Redis + DB)
  • Concurrent-Safe (No duplicate numbers)
  • Support Disciplines
  • Year-Based Reset

📝 Acceptance Criteria

  1. Number Generation:

    • Generate unique sequential numbers
    • Support format: {ORG}-{TYPE}-{DISCIPLINE}-{YEAR}-{SEQ:4}
    • No duplicates even with 100+ concurrent requests
    • Generate within 100ms (p90)
  2. Lock Mechanism:

    • Redis lock acquired (TTL: 3 seconds)
    • DB optimistic lock with version column
    • Retry on conflict (3 times max)
    • Exponential backoff
  3. Format Templates:

    • Configure per Project/Type
    • Support all token types
    • Validate format before use

🛠️ Implementation Steps

1. Entity - Document Number Format

// File: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
@Entity('document_number_formats')
export class DocumentNumberFormat {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  project_id: number;

  @Column()
  correspondence_type_id: number;

  @Column({ length: 255 })
  format_template: string;
  // Example: '{ORG_CODE}-{TYPE_CODE}-{DISCIPLINE_CODE}-{YEAR}-{SEQ:4}'

  @Column({ type: 'text', nullable: true })
  description: string;

  @CreateDateColumn()
  created_at: Date;

  @ManyToOne(() => Project)
  @JoinColumn({ name: 'project_id' })
  project: Project;

  @ManyToOne(() => CorrespondenceType)
  @JoinColumn({ name: 'correspondence_type_id' })
  correspondenceType: CorrespondenceType;
}

2. Entity - Document Number Counter

// File: backend/src/modules/document-numbering/entities/document-number-counter.entity.ts
@Entity('document_number_counters')
export class DocumentNumberCounter {
  @PrimaryColumn()
  project_id: number;

  @PrimaryColumn()
  originator_organization_id: number;

  @PrimaryColumn()
  correspondence_type_id: number;

  @PrimaryColumn({ default: 0 })
  discipline_id: number;

  @PrimaryColumn()
  current_year: number;

  @Column({ default: 0 })
  last_number: number;

  @VersionColumn() // Optimistic Lock
  version: number;

  @UpdateDateColumn()
  updated_at: Date;
}

3. Numbering Service

// File: backend/src/modules/document-numbering/document-numbering.service.ts
import Redlock from 'redlock';

interface NumberingContext {
  projectId: number;
  organizationId: number;
  typeId: number;
  disciplineId?: number;
  year?: number;
}

@Injectable()
export class DocumentNumberingService {
  constructor(
    @InjectRepository(DocumentNumberCounter)
    private counterRepo: Repository<DocumentNumberCounter>,
    @InjectRepository(DocumentNumberFormat)
    private formatRepo: Repository<DocumentNumberFormat>,
    @InjectRepository(Organization)
    private orgRepo: Repository<Organization>,
    @InjectRepository(CorrespondenceType)
    private typeRepo: Repository<CorrespondenceType>,
    @InjectRepository(Discipline)
    private disciplineRepo: Repository<Discipline>,
    private redlock: Redlock,
    private logger: Logger
  ) {}

  async generateNextNumber(context: NumberingContext): Promise<string> {
    const year = context.year || new Date().getFullYear();
    const disciplineId = context.disciplineId || 0;

    // Build Redis lock key
    const lockKey = this.buildLockKey(
      context.projectId,
      context.organizationId,
      context.typeId,
      disciplineId,
      year
    );

    // Retry logic with exponential backoff
    return this.retryWithBackoff(
      async () =>
        await this.generateNumberWithLock(lockKey, context, year, disciplineId),
      3,
      200
    );
  }

  private async generateNumberWithLock(
    lockKey: string,
    context: NumberingContext,
    year: number,
    disciplineId: number
  ): Promise<string> {
    // Step 1: Acquire Redis lock
    const lock = await this.redlock.acquire([lockKey], 3000); // 3 sec TTL

    try {
      // Step 2: Get or create counter
      let counter = await this.counterRepo.findOne({
        where: {
          project_id: context.projectId,
          originator_organization_id: context.organizationId,
          correspondence_type_id: context.typeId,
          discipline_id: disciplineId,
          current_year: year,
        },
      });

      if (!counter) {
        // Initialize new counter
        counter = this.counterRepo.create({
          project_id: context.projectId,
          originator_organization_id: context.organizationId,
          correspondence_type_id: context.typeId,
          discipline_id: disciplineId,
          current_year: year,
          last_number: 0,
          version: 0,
        });
        await this.counterRepo.save(counter);
      }

      const currentVersion = counter.version;
      const nextNumber = counter.last_number + 1;

      // Step 3: Update counter with Optimistic Lock
      const result = await this.counterRepo
        .createQueryBuilder()
        .update(DocumentNumberCounter)
        .set({
          last_number: nextNumber,
        })
        .where({
          project_id: context.projectId,
          originator_organization_id: context.organizationId,
          correspondence_type_id: context.typeId,
          discipline_id: disciplineId,
          current_year: year,
          version: currentVersion, // Optimistic lock check
        })
        .execute();

      if (result.affected === 0) {
        throw new ConflictException('Counter version conflict - retrying...');
      }

      // Step 4: Format number
      const formattedNumber = await this.formatNumber({
        projectId: context.projectId,
        typeId: context.typeId,
        organizationId: context.organizationId,
        disciplineId,
        year,
        sequenceNumber: nextNumber,
      });

      this.logger.log(`Generated number: ${formattedNumber}`);
      return formattedNumber;
    } finally {
      // Step 5: Release lock
      await lock.release();
    }
  }

  private async formatNumber(data: any): Promise<string> {
    // Get format template
    const format = await this.formatRepo.findOne({
      where: {
        project_id: data.projectId,
        correspondence_type_id: data.typeId,
      },
    });

    if (!format) {
      throw new NotFoundException('Document number format not found');
    }

    // Parse and replace tokens
    let result = format.format_template;

    const tokens = await this.buildTokens(data);
    for (const [token, value] of Object.entries(tokens)) {
      result = result.replace(token, value);
    }

    return result;
  }

  private async buildTokens(data: any): Promise<Record<string, string>> {
    const org = await this.orgRepo.findOne({
      where: { id: data.organizationId },
    });
    const type = await this.typeRepo.findOne({ where: { id: data.typeId } });
    let discipline = null;

    if (data.disciplineId > 0) {
      discipline = await this.disciplineRepo.findOne({
        where: { id: data.disciplineId },
      });
    }

    return {
      '{ORG_CODE}': org?.organization_code || 'ORG',
      '{TYPE_CODE}': type?.type_code || 'TYPE',
      '{DISCIPLINE_CODE}': discipline?.discipline_code || 'GEN',
      '{YEAR}': data.year.toString(),
      '{SEQ:4}': data.sequenceNumber.toString().padStart(4, '0'),
      '{SEQ:5}': data.sequenceNumber.toString().padStart(5, '0'),
    };
  }

  private buildLockKey(...parts: Array<number | string>): string {
    return `doc_num:${parts.join(':')}`;
  }

  private async retryWithBackoff<T>(
    fn: () => Promise<T>,
    maxRetries: number,
    initialDelay: number
  ): Promise<T> {
    let lastError: Error;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await fn();
      } catch (error) {
        if (!(error instanceof ConflictException) || attempt === maxRetries) {
          throw error;
        }

        lastError = error;
        const delay = initialDelay * Math.pow(2, attempt);
        await new Promise((resolve) => setTimeout(resolve, delay));
        this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
      }
    }

    throw lastError;
  }
}

4. Module

// File: backend/src/modules/document-numbering/document-numbering.module.ts
@Module({
  imports: [
    TypeOrmModule.forFeature([
      DocumentNumberCounter,
      DocumentNumberFormat,
      Organization,
      CorrespondenceType,
      Discipline,
    ]),
    RedisModule,
  ],
  providers: [DocumentNumberingService],
  exports: [DocumentNumberingService],
})
export class DocumentNumberingModule {}

Testing & Verification

1. Concurrent Test

describe('DocumentNumberingService - Concurrency', () => {
  it('should generate 100 unique numbers concurrently', async () => {
    const context = {
      projectId: 1,
      organizationId: 3,
      typeId: 1,
      disciplineId: 2,
      year: 2025,
    };

    const promises = Array(100)
      .fill(null)
      .map(() => service.generateNextNumber(context));

    const results = await Promise.all(promises);

    // Check uniqueness
    const unique = new Set(results);
    expect(unique.size).toBe(100);

    // Check format
    results.forEach((num) => {
      expect(num).toMatch(/^TEAM-RFA-STR-2025-\d{4}$/);
    });
  });

  it('should handle Redis lock timeout', async () => {
    // Mock Redis lock to always timeout
    jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));

    await expect(service.generateNextNumber(context)).rejects.toThrow();
  });

  it('should retry on version conflict', async () => {
    // Simulate conflict on first attempt
    let attempt = 0;
    jest.spyOn(counterRepo, 'createQueryBuilder').mockImplementation(() => {
      attempt++;
      return {
        update: () => ({
          set: () => ({
            where: () => ({
              execute: async () => ({
                affected: attempt === 1 ? 0 : 1, // Fail first, succeed second
              }),
            }),
          }),
        }),
      } as any;
    });

    const result = await service.generateNextNumber(context);
    expect(result).toBeDefined();
    expect(attempt).toBe(2);
  });
});

2. Load Test

# artillery.yml
config:
  target: 'http://localhost:3000'
  phases:
    - duration: 30
      arrivalRate: 20 # 20 req/sec

scenarios:
  - name: 'Generate Document Numbers'
    flow:
      - post:
          url: '/correspondences'
          json:
            title: 'Load Test {{ $randomString() }}'
            project_id: 1
            type_id: 1
            discipline_id: 2


📦 Deliverables

  • DocumentNumberingService
  • DocumentNumberCounter Entity
  • DocumentNumberFormat Entity
  • Format Template Parser
  • Redis Lock Integration
  • Retry Logic with Backoff
  • Unit Tests (90% coverage)
  • Concurrent Tests
  • Load Tests
  • Documentation

🚨 Risks & Mitigation

Risk Impact Mitigation
Redis lock failure High Retry + DB fallback
Version conflicts Medium Exponential backoff retry
Lock timeout Medium Increase TTL, optimize queries
Performance degradation High Redis caching, connection pooling

📌 Notes

  • Redis lock TTL: 3 seconds
  • Max retries: 3
  • Exponential backoff: 200ms → 400ms → 800ms
  • Format template stored in database (configurable)
  • Counters reset automatically per year