# Backend Implementation Guide: Document Numbering **Version**: 1.0.0 **Last Updated**: 2025-01-16 **Status**: APPROVED **Related**: [Requirements](../01-requirements/21-document-numbering-requirements.md), [ADR-018](../05-decisions/adr-018-document-numbering.md) --- ## 1. Architecture Overview ### 1.1 Module Structure ``` backend/src/modules/document-numbering/ ├── document-numbering.module.ts ├── controllers/ │ ├── numbering.controller.ts │ ├── numbering-admin.controller.ts │ └── numbering-metrics.controller.ts ├── services/ │ ├── sequence.service.ts │ ├── reservation.service.ts │ ├── manual-override.service.ts │ ├── void-replace.service.ts │ ├── format.service.ts │ ├── metrics.service.ts │ └── migration.service.ts ├── entities/ │ ├── numbering-config.entity.ts │ ├── numbering-sequence.entity.ts │ ├── numbering-audit-log.entity.ts │ └── numbering-reservation.entity.ts ├── dto/ │ ├── reserve-number.dto.ts │ ├── confirm-reservation.dto.ts │ ├── manual-override.dto.ts │ ├── void-document.dto.ts │ └── bulk-import.dto.ts ├── guards/ │ └── manual-override.guard.ts ├── decorators/ │ └── audit-numbering.decorator.ts ├── interfaces/ │ └── numbering.interface.ts └── tests/ ├── unit/ ├── integration/ └── e2e/ ``` --- ## 2. Core Entities ### 2.1 Numbering Configuration ```typescript // entities/numbering-config.entity.ts import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; @Entity('document_numbering_configs') export class NumberingConfig { @PrimaryGeneratedColumn() id: number; @Column({ length: 50, unique: true }) document_type: string; @Column({ length: 200 }) format: string; @Column({ type: 'enum', enum: ['GLOBAL', 'PROJECT', 'CONTRACT', 'YEARLY', 'MONTHLY'], default: 'GLOBAL' }) scope: string; @Column({ default: false }) allow_manual_override: boolean; @Column({ default: 999999 }) max_value: number; @Column({ type: 'json', nullable: true }) metadata: Record; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) created_at: Date; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' }) updated_at: Date; @OneToMany(() => NumberingSequence, sequence => sequence.config) sequences: NumberingSequence[]; } ``` ### 2.2 Sequence Counter ```typescript // entities/numbering-sequence.entity.ts import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; @Entity('document_numbering_sequences') export class NumberingSequence { @PrimaryGeneratedColumn() id: number; @Column() config_id: number; @Column({ length: 50, nullable: true }) scope_value: string; // project_id, contract_id, year, etc. @Column({ default: 0 }) current_value: number; @Column({ type: 'timestamp', nullable: true }) last_used_at: Date; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) created_at: Date; @ManyToOne(() => NumberingConfig, config => config.sequences) @JoinColumn({ name: 'config_id' }) config: NumberingConfig; // Composite unique constraint @Index(['config_id', 'scope_value'], { unique: true }) } ``` ### 2.3 Audit Log ```typescript // entities/numbering-audit-log.entity.ts import { Entity, Column, PrimaryGeneratedColumn, Index } from 'typeorm'; @Entity('document_numbering_audit_logs') export class NumberingAuditLog { @PrimaryGeneratedColumn('increment', { type: 'bigint' }) id: bigint; @Column({ length: 50 }) @Index() operation: string; // RESERVE, CONFIRM, CANCEL, MANUAL_OVERRIDE, VOID @Column({ length: 50, nullable: true }) document_type: string; @Column({ length: 50, nullable: true }) @Index() document_number: string; @Column({ type: 'text', nullable: true }) old_value: string; @Column({ type: 'text', nullable: true }) new_value: string; @Column() @Index() user_id: number; @Column({ length: 45, nullable: true }) ip_address: string; @Column({ length: 500, nullable: true }) user_agent: string; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @Index() timestamp: Date; @Column({ type: 'json', nullable: true }) metadata: Record; } ``` --- ## 3. Core Services ### 3.1 Sequence Service ```typescript // services/sequence.service.ts import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Redlock } from 'redlock'; import { NumberingConfig, NumberingSequence } from '../entities'; import { FormatService } from './format.service'; @Injectable() export class SequenceService { private readonly logger = new Logger(SequenceService.name); constructor( @InjectRepository(NumberingConfig) private configRepo: Repository, @InjectRepository(NumberingSequence) private sequenceRepo: Repository, private dataSource: DataSource, private redlock: Redlock, private formatService: FormatService, ) {} /** * Get next sequence number with distributed locking */ async getNextSequence( documentType: string, scopeValue?: string, ): Promise { // 1. Get configuration const config = await this.getConfig(documentType); // 2. Build lock key const lockKey = this.buildLockKey(documentType, scopeValue); // 3. Try with Redlock first try { return await this.getSequenceWithRedlock(config, scopeValue, lockKey); } catch (error) { if (this.isRedisUnavailable(error)) { // Fallback to database-only mode this.logger.warn('Redis unavailable, using DB-only mode'); return await this.getSequenceWithDbLock(config, scopeValue); } throw error; } } /** * Get sequence with Redlock + Database pessimistic lock */ private async getSequenceWithRedlock( config: NumberingConfig, scopeValue: string, lockKey: string, ): Promise { // Acquire distributed lock const lock = await this.redlock.acquire([lockKey], 5000, { retryCount: 3, retryDelay: 200, retryJitter: 100, }); try { return await this.dataSource.transaction(async (manager) => { // Get or create sequence with pessimistic lock let sequence = await manager.findOne(NumberingSequence, { where: { config_id: config.id, scope_value: scopeValue || null, }, lock: { mode: 'pessimistic_write' }, }); if (!sequence) { sequence = await this.createSequence(manager, config, scopeValue); } // Increment sequence const nextValue = await this.incrementSequence( manager, sequence, config, ); // Format number return this.formatService.formatNumber(config.format, nextValue, { documentType: config.document_type, scopeValue, }); }); } finally { // Always release lock await lock.release(); } } /** * Fallback: Database-only locking (no Redis) */ private async getSequenceWithDbLock( config: NumberingConfig, scopeValue: string, ): Promise { return await this.dataSource.transaction(async (manager) => { let sequence = await manager.findOne(NumberingSequence, { where: { config_id: config.id, scope_value: scopeValue || null, }, lock: { mode: 'pessimistic_write' }, }); if (!sequence) { sequence = await this.createSequence(manager, config, scopeValue); } const nextValue = await this.incrementSequence( manager, sequence, config, ); return this.formatService.formatNumber(config.format, nextValue, { documentType: config.document_type, scopeValue, }); }); } /** * Increment sequence, skip cancelled numbers */ private async incrementSequence( manager: EntityManager, sequence: NumberingSequence, config: NumberingConfig, ): Promise { let nextValue = sequence.current_value + 1; // Skip cancelled numbers while (await this.isCancelledNumber(manager, config, nextValue)) { this.logger.debug(`Skipping cancelled number: ${nextValue}`); nextValue++; } // Check max value if (nextValue > config.max_value) { throw new SequenceExhaustedError( `Sequence exhausted for ${config.document_type}. Max: ${config.max_value}`, ); } // Update sequence sequence.current_value = nextValue; sequence.last_used_at = new Date(); await manager.save(sequence); return nextValue; } /** * Check if number is cancelled */ private async isCancelledNumber( manager: EntityManager, config: NumberingConfig, value: number, ): Promise { const count = await manager.count(NumberingAuditLog, { where: { document_type: config.document_type, operation: 'CANCEL', metadata: { sequence_value: value }, }, }); return count > 0; } /** * Create new sequence */ private async createSequence( manager: EntityManager, config: NumberingConfig, scopeValue: string, ): Promise { const sequence = manager.create(NumberingSequence, { config_id: config.id, scope_value: scopeValue || null, current_value: 0, }); return await manager.save(sequence); } /** * Build lock key for Redlock */ private buildLockKey(documentType: string, scopeValue?: string): string { const parts = ['numbering', documentType]; if (scopeValue) parts.push(scopeValue); return parts.join(':'); } /** * Check if error is Redis unavailable */ private isRedisUnavailable(error: any): boolean { return error.message?.includes('Redis') || error.message?.includes('ECONNREFUSED'); } /** * Get configuration (cached) */ @Cacheable({ ttl: 3600, key: 'numbering:config:{documentType}' }) private async getConfig(documentType: string): Promise { const config = await this.configRepo.findOne({ where: { document_type: documentType }, }); if (!config) { throw new ConfigNotFoundError( `Numbering config not found for ${documentType}`, ); } return config; } } ``` --- ### 3.2 Reservation Service ```typescript // services/reservation.service.ts import { Injectable, Logger } from '@nestjs/common'; import { Redis } from 'ioredis'; import { v4 as uuidv4 } from 'uuid'; import { SequenceService } from './sequence.service'; import { AuditService } from './audit.service'; interface Reservation { token: string; document_number: string; document_type: string; scope_value?: string; expires_at: Date; metadata?: Record; } @Injectable() export class ReservationService { private readonly logger = new Logger(ReservationService.name); private readonly TTL = 300; // 5 minutes constructor( private redis: Redis, private sequenceService: SequenceService, private auditService: AuditService, ) {} /** * Reserve a document number */ async reserve( documentType: string, scopeValue?: string, metadata?: Record, ): Promise { // 1. Generate next number const documentNumber = await this.sequenceService.getNextSequence( documentType, scopeValue, ); // 2. Generate reservation token const token = uuidv4(); // 3. Calculate expiry const expiresAt = new Date(Date.now() + this.TTL * 1000); // 4. Save reservation to Redis const reservation: Reservation = { token, document_number: documentNumber, document_type: documentType, scope_value: scopeValue, expires_at: expiresAt, metadata, }; await this.redis.setex( `reservation:${token}`, this.TTL, JSON.stringify(reservation), ); // 5. Audit log await this.auditService.log({ operation: 'RESERVE', document_type: documentType, document_number: documentNumber, metadata: { token, scope_value: scopeValue }, }); this.logger.log(`Reserved number: ${documentNumber}, token: ${token}`); return reservation; } /** * Confirm reservation */ async confirm(token: string, userId: number): Promise { // 1. Get reservation from Redis const reservation = await this.getReservation(token); if (!reservation) { throw new ReservationExpiredError( 'Reservation not found or expired. Please reserve a new number.', ); } // 2. Save to database (via document creation) // Note: Actual document creation happens in the calling service // 3. Delete reservation from Redis await this.redis.del(`reservation:${token}`); // 4. Audit log await this.auditService.log({ operation: 'CONFIRM', document_type: reservation.document_type, document_number: reservation.document_number, user_id: userId, metadata: { token }, }); this.logger.log(`Confirmed reservation: ${reservation.document_number}`); return reservation.document_number; } /** * Cancel reservation */ async cancel(token: string, userId: number): Promise { const reservation = await this.getReservation(token); if (reservation) { // Delete reservation await this.redis.del(`reservation:${token}`); // Audit log await this.auditService.log({ operation: 'CANCEL', document_type: reservation.document_type, document_number: reservation.document_number, user_id: userId, metadata: { token }, }); this.logger.log(`Cancelled reservation: ${reservation.document_number}`); } } /** * Get reservation from Redis */ private async getReservation(token: string): Promise { const data = await this.redis.get(`reservation:${token}`); return data ? JSON.parse(data) : null; } /** * Cleanup expired reservations (scheduled job) */ @Cron('0 */5 * * * *') // Every 5 minutes async cleanupExpired(): Promise { const keys = await this.redis.keys('reservation:*'); for (const key of keys) { const ttl = await this.redis.ttl(key); if (ttl <= 0) { await this.redis.del(key); this.logger.debug(`Cleaned up expired reservation: ${key}`); } } } } ``` --- ### 3.3 Manual Override Service ```typescript // services/manual-override.service.ts import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { NumberingConfig, NumberingSequence } from '../entities'; import { FormatService } from './format.service'; import { AuditService } from './audit.service'; @Injectable() export class ManualOverrideService { private readonly logger = new Logger(ManualOverrideService.name); constructor( @InjectRepository(NumberingConfig) private configRepo: Repository, @InjectRepository(NumberingSequence) private sequenceRepo: Repository, private dataSource: DataSource, private formatService: FormatService, private auditService: AuditService, ) {} /** * Create document with manual number */ async createWithManualNumber( documentType: string, manualNumber: string, userId: number, reason: string, skipValidation = false, ): Promise { // 1. Get configuration const config = await this.configRepo.findOne({ where: { document_type: documentType }, }); if (!config) { throw new ConfigNotFoundError(`Config not found for ${documentType}`); } if (!config.allow_manual_override) { throw new ManualOverrideNotAllowedError( `Manual override not allowed for ${documentType}`, ); } // 2. Validate if (!skipValidation) { await this.validate(manualNumber, config); } // 3. Check duplicate const exists = await this.checkDuplicate(manualNumber); if (exists) { throw new DuplicateNumberError( `Number ${manualNumber} already exists`, ); } // 4. Update sequence if higher await this.updateSequenceIfHigher( documentType, manualNumber, config, ); // 5. Audit log await this.auditService.log({ operation: 'MANUAL_OVERRIDE', document_type: documentType, document_number: manualNumber, user_id: userId, metadata: { reason, skip_validation: skipValidation }, }); this.logger.log(`Manual override: ${manualNumber} by user ${userId}`); } /** * Validate manual number format */ private async validate( manualNumber: string, config: NumberingConfig, ): Promise { const isValid = this.formatService.matchesFormat( manualNumber, config.format, ); if (!isValid) { throw new InvalidFormatError( `Number ${manualNumber} does not match format ${config.format}`, ); } } /** * Check if number already exists */ private async checkDuplicate(number: string): Promise { // Check in your document tables // This is a placeholder - implement based on your schema const count = await this.dataSource.query( ` SELECT COUNT(*) as count FROM ( SELECT document_number FROM correspondences WHERE document_number = ? UNION ALL SELECT document_number FROM rfas WHERE document_number = ? UNION ALL SELECT document_number FROM drawings WHERE document_number = ? ) AS all_docs `, [number, number, number], ); return count[0].count > 0; } /** * Update sequence counter if manual number is higher */ private async updateSequenceIfHigher( documentType: string, manualNumber: string, config: NumberingConfig, ): Promise { // Extract sequence value from manual number const sequenceValue = this.formatService.extractSequence( manualNumber, config.format, ); if (!sequenceValue) { this.logger.warn(`Could not extract sequence from ${manualNumber}`); return; } // Update sequence if higher await this.dataSource.transaction(async (manager) => { const sequence = await manager.findOne(NumberingSequence, { where: { config_id: config.id }, lock: { mode: 'pessimistic_write' }, }); if (sequence && sequenceValue > sequence.current_value) { sequence.current_value = sequenceValue; sequence.last_used_at = new Date(); await manager.save(sequence); this.logger.log( `Updated sequence for ${documentType} to ${sequenceValue}`, ); } }); } } ``` --- ## 4. Controllers ### 4.1 Main Numbering Controller ```typescript // controllers/numbering.controller.ts import { Controller, Post, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../../auth/guards'; import { CurrentUser } from '../../common/decorators'; import { ReservationService } from '../services'; import { ReserveNumberDto, ConfirmReservationDto } from '../dto'; @ApiTags('Document Numbering') @ApiBearerAuth() @Controller('document-numbering') @UseGuards(JwtAuthGuard) export class NumberingController { constructor( private reservationService: ReservationService, ) {} @Post('reserve') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Reserve a document number' }) async reserve( @Body() dto: ReserveNumberDto, @CurrentUser() user: any, ) { const reservation = await this.reservationService.reserve( dto.document_type, dto.scope_value, dto.metadata, ); return { success: true, data: reservation, }; } @Post('confirm') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Confirm a reservation' }) async confirm( @Body() dto: ConfirmReservationDto, @CurrentUser() user: any, ) { const documentNumber = await this.reservationService.confirm( dto.token, user.id, ); return { success: true, data: { document_number: documentNumber }, }; } @Post('cancel') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Cancel a reservation' }) async cancel( @Body() dto: ConfirmReservationDto, @CurrentUser() user: any, ) { await this.reservationService.cancel(dto.token, user.id); return { success: true, message: 'Reservation cancelled', }; } } ``` --- ## 5. Integration with Document Creation ### 5.1 Correspondence Example ```typescript // modules/correspondence/services/correspondence.service.ts @Injectable() export class CorrespondenceService { constructor( private reservationService: ReservationService, private dataSource: DataSource, ) {} async create(dto: CreateCorrespondenceDto, userId: number) { // Phase 1: Reserve number const { token, document_number } = await this.reservationService.reserve( 'COR', dto.project_id.toString(), ); try { // Phase 2: Create document in transaction const correspondence = await this.dataSource.transaction( async (manager) => { // Create correspondence const corr = manager.create(Correspondence, { document_number, ...dto, created_by: userId, }); await manager.save(corr); // Confirm reservation await this.reservationService.confirm(token, userId); return corr; }, ); return correspondence; } catch (error) { // Phase 2 failed: Cancel reservation await this.reservationService.cancel(token, userId); throw error; } } } ``` --- ## 6. Testing ### 6.1 Unit Tests ```typescript // tests/unit/sequence.service.spec.ts describe('SequenceService', () => { let service: SequenceService; let configRepo: Repository; let sequenceRepo: Repository; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ SequenceService, { provide: getRepositoryToken(NumberingConfig), useValue: mockRepo }, { provide: getRepositoryToken(NumberingSequence), useValue: mockRepo }, { provide: DataSource, useValue: mockDataSource }, { provide: Redlock, useValue: mockRedlock }, { provide: FormatService, useValue: mockFormatService }, ], }).compile(); service = module.get(SequenceService); }); it('should generate sequential numbers', async () => { const num1 = await service.getNextSequence('COR'); const num2 = await service.getNextSequence('COR'); expect(extractSeq(num1)).toBe(1); expect(extractSeq(num2)).toBe(2); }); it('should skip cancelled numbers', async () => { // Mark sequence 2 as cancelled await markAsCancelled('COR', 2); const num1 = await service.getNextSequence('COR'); const num2 = await service.getNextSequence('COR'); expect(extractSeq(num1)).toBe(1); expect(extractSeq(num2)).toBe(3); // Skipped 2 }); it('should throw on sequence exhausted', async () => { await setSequence('COR', 999999); // Max value await expect( service.getNextSequence('COR') ).rejects.toThrow(SequenceExhaustedError); }); }); ``` ### 6.2 Integration Tests ```typescript // tests/integration/reservation.spec.ts describe('Reservation Flow (Integration)', () => { let app: INestApplication; let redis: Redis; beforeAll(async () => { app = await createTestingApp(); redis = app.get(Redis); }); it('should complete two-phase commit successfully', async () => { // Phase 1: Reserve const { body: reserve } = await request(app.getHttpServer()) .post('/document-numbering/reserve') .send({ document_type: 'COR' }) .expect(201); expect(reserve.data.token).toBeDefined(); expect(reserve.data.document_number).toMatch(/^COR-\d{4}-\d{5}$/); // Verify reservation in Redis const cached = await redis.get(`reservation:${reserve.data.token}`); expect(cached).toBeDefined(); // Phase 2: Confirm const { body: confirm } = await request(app.getHttpServer()) .post('/document-numbering/confirm') .send({ token: reserve.data.token }) .expect(200); expect(confirm.data.document_number).toBe(reserve.data.document_number); // Verify reservation deleted const deleted = await redis.get(`reservation:${reserve.data.token}`); expect(deleted).toBeNull(); }); it('should handle cancel gracefully', async () => { const { body: reserve } = await request(app.getHttpServer()) .post('/document-numbering/reserve') .send({ document_type: 'COR' }) .expect(201); await request(app.getHttpServer()) .post('/document-numbering/cancel') .send({ token: reserve.data.token }) .expect(200); // Verify reservation deleted const deleted = await redis.get(`reservation:${reserve.data.token}`); expect(deleted).toBeNull(); }); }); ``` ### 6.3 Load Tests ```typescript // tests/load/concurrency.spec.ts describe('Concurrency Test', () => { it('should handle 1000 concurrent requests without duplicates', async () => { const promises = Array.from({ length: 1000 }, (_, i) => request(app.getHttpServer()) .post('/document-numbering/reserve') .send({ document_type: 'COR' }) ); const results = await Promise.all(promises); // Extract all document numbers const numbers = results.map(r => r.body.data.document_number); // Check for duplicates const uniqueNumbers = new Set(numbers); expect(uniqueNumbers.size).toBe(1000); // Verify sequential const sequences = numbers.map(n => extractSeq(n)).sort((a, b) => a - b); expect(sequences[0]).toBe(1); expect(sequences[999]).toBe(1000); }); }); ``` --- ## 7. Deployment Checklist ### 7.1 Pre-Deployment - [ ] Run all tests (unit, integration, E2E) - [ ] Load test (1000 req/s for 5 min) - [ ] Setup Redis cluster (3 nodes) - [ ] Run database migrations - [ ] Configure environment variables - [ ] Setup monitoring (Prometheus + Grafana) - [ ] Configure alerts (PagerDuty/Slack) - [ ] Review security settings - [ ] Backup database - [ ] Document rollback procedure ### 7.2 Deployment Steps 1. Deploy Redis cluster to staging 2. Run migrations on staging database 3. Deploy backend service to staging 4. Run smoke tests on staging 5. Load test staging environment 6. Get approval from stakeholders 7. Deploy to production (blue-green deployment) 8. Monitor for 1 hour 9. Gradual rollout (10% → 50% → 100%) ### 7.3 Post-Deployment - [ ] Verify all metrics green - [ ] Check error rates (<0.1%) - [ ] Validate audit logs working - [ ] Test critical workflows - [ ] Monitor performance for 24 hours - [ ] Collect user feedback - [ ] Schedule retrospective --- ## 8. Monitoring & Observability ### 8.1 Prometheus Metrics ```typescript // metrics/numbering.metrics.ts import { Injectable } from '@nestjs/common'; import { Counter, Gauge, Histogram, register } from 'prom-client'; @Injectable() export class NumberingMetrics { // Counter: Total numbers generated private readonly numbersGenerated = new Counter({ name: 'numbering_sequences_total', help: 'Total document numbers generated', labelNames: ['document_type'], }); // Gauge: Current sequence value private readonly sequenceValue = new Gauge({ name: 'numbering_sequence_current', help: 'Current sequence value', labelNames: ['document_type', 'scope'], }); // Gauge: Sequence utilization (%) private readonly sequenceUtilization = new Gauge({ name: 'numbering_sequence_utilization', help: 'Sequence utilization percentage', labelNames: ['document_type'], }); // Histogram: Lock wait time private readonly lockWaitTime = new Histogram({ name: 'numbering_lock_wait_seconds', help: 'Time spent waiting for lock acquisition', labelNames: ['document_type'], buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], }); // Counter: Lock failures private readonly lockFailures = new Counter({ name: 'numbering_lock_failures_total', help: 'Total lock acquisition failures', labelNames: ['document_type', 'reason'], }); // Counter: Manual overrides private readonly manualOverrides = new Counter({ name: 'numbering_manual_overrides_total', help: 'Total manual overrides', labelNames: ['document_type'], }); incrementNumbersGenerated(documentType: string) { this.numbersGenerated.inc({ document_type: documentType }); } setSequenceValue(documentType: string, scope: string, value: number) { this.sequenceValue.set({ document_type: documentType, scope }, value); } setSequenceUtilization(documentType: string, percent: number) { this.sequenceUtilization.set({ document_type: documentType }, percent); } observeLockWaitTime(documentType: string, seconds: number) { this.lockWaitTime.observe({ document_type: documentType }, seconds); } incrementLockFailures(documentType: string, reason: string) { this.lockFailures.inc({ document_type: documentType, reason }); } incrementManualOverrides(documentType: string) { this.manualOverrides.inc({ document_type: documentType }); } } ``` ### 8.2 Grafana Dashboard ```json { "dashboard": { "title": "Document Numbering", "panels": [ { "title": "Numbers Generated per Minute", "targets": [ { "expr": "rate(numbering_sequences_total[1m])" } ] }, { "title": "Sequence Utilization", "targets": [ { "expr": "numbering_sequence_utilization" } ], "thresholds": [90, 95] }, { "title": "Lock Wait Time (p95)", "targets": [ { "expr": "histogram_quantile(0.95, numbering_lock_wait_seconds)" } ] }, { "title": "Lock Failures", "targets": [ { "expr": "rate(numbering_lock_failures_total[5m])" } ] } ] } } ``` ### 8.3 Alert Rules ```yaml # alerts/numbering.yml groups: - name: numbering_alerts interval: 30s rules: # Critical: Sequence >95% used - alert: SequenceCritical expr: numbering_sequence_utilization > 95 for: 5m labels: severity: critical annotations: summary: "Sequence {{ $labels.document_type }} >95% used" description: "Current: {{ $value }}%. Extend max_value immediately." # Warning: Sequence >90% used - alert: SequenceWarning expr: numbering_sequence_utilization > 90 for: 10m labels: severity: warning annotations: summary: "Sequence {{ $labels.document_type }} >90% used" description: "Current: {{ $value }}%. Plan to extend max_value." # Critical: High lock wait time - alert: HighLockWaitTime expr: histogram_quantile(0.95, numbering_lock_wait_seconds) > 1 for: 5m labels: severity: warning annotations: summary: "Lock wait time >1s (p95)" description: "p95: {{ $value }}s. Check Redis cluster health." # Critical: Redis down - alert: RedisUnavailable expr: up{job="redis-numbering"} == 0 for: 1m labels: severity: critical annotations: summary: "Redis cluster unavailable" description: "Numbering system using DB-only fallback mode." # Warning: High error rate - alert: HighErrorRate expr: rate(numbering_errors_total[5m]) > 10 for: 5m labels: severity: warning annotations: summary: "High error rate in numbering system" description: "{{ $value }} errors/sec. Check logs." ``` --- ## 9. Troubleshooting Guide ### 9.1 Common Issues #### Issue 1: Duplicate Numbers Generated **Symptoms**: Same document number appears twice **Diagnosis**: ```sql -- Find duplicates SELECT document_number, COUNT(*) as count FROM ( SELECT document_number FROM correspondences UNION ALL SELECT document_number FROM rfas UNION ALL SELECT document_number FROM drawings ) AS all_docs GROUP BY document_number HAVING count > 1; ``` **Root Causes**: - Redis cluster failure during generation - Database deadlock - Bug in locking logic **Resolution**: 1. Identify affected documents 2. Manually reassign one with new number 3. Update audit log 4. Review lock acquisition logs 5. Add additional monitoring --- #### Issue 2: Sequence Exhausted **Symptoms**: Error "Sequence exhausted for COR" **Diagnosis**: ```sql -- Check current vs max SELECT c.document_type, s.current_value, c.max_value, (s.current_value * 100.0 / c.max_value) as utilization FROM document_numbering_sequences s JOIN document_numbering_configs c ON s.config_id = c.id WHERE s.current_value >= c.max_value * 0.9; ``` **Resolution**: ```sql -- Extend max_value UPDATE document_numbering_configs SET max_value = max_value * 10 WHERE document_type = 'COR'; -- Or reset yearly sequence (if applicable) UPDATE document_numbering_sequences SET current_value = 0, scope_value = '2026' -- New year WHERE config_id = ( SELECT id FROM document_numbering_configs WHERE document_type = 'COR' ); ``` --- #### Issue 3: Lock Timeout **Symptoms**: "Failed to acquire lock after 3 retries" **Diagnosis**: ```bash # Check Redis cluster health redis-cli --cluster check localhost:7000 # Check lock contention redis-cli KEYS "numbering:*" redis-cli GET "numbering:COR:project-1" ``` **Root Causes**: - High concurrent load - Redis node down - Network latency - Deadlock in database **Resolution**: 1. Check Redis cluster health 2. Increase lock timeout (5s → 10s) 3. Add more Redis nodes 4. Review database slow queries 5. Implement exponential backoff --- #### Issue 4: Reservation Expired **Symptoms**: User gets "Reservation expired" error **Diagnosis**: ```bash # Check Redis TTL redis-cli TTL "reservation:uuid-here" # List all reservations redis-cli KEYS "reservation:*" ``` **Root Causes**: - User took >5 minutes to complete form - Network issue during confirmation - Browser closed/refreshed **Resolution**: 1. Reserve new number 2. Consider increasing TTL (5 min → 10 min) 3. Add progress auto-save 4. Show countdown timer in UI --- ### 9.2 Debug Commands ```bash # Check sequence status npm run cli numbering:status COR # Manually adjust sequence npm run cli numbering:set COR 1000 # Validate sequence integrity npm run cli numbering:validate # Export audit logs npm run cli numbering:audit-export \ --start "2025-01-01" \ --end "2025-01-31" \ --format csv \ --output audit.csv # Simulate load npm run cli numbering:load-test \ --type COR \ --requests 1000 \ --concurrency 100 # Check for duplicates npm run cli numbering:check-duplicates ``` --- ## 10. Performance Optimization ### 10.1 Database Indexes ```sql -- Composite index for faster lookups CREATE INDEX idx_sequence_lookup ON document_numbering_sequences(config_id, scope_value); -- Covering index for metrics CREATE INDEX idx_audit_metrics ON document_numbering_audit_logs(document_type, timestamp) INCLUDE (operation, user_id); -- Index for duplicate checking CREATE INDEX idx_doc_number ON correspondences(document_number); CREATE INDEX idx_doc_number ON rfas(document_number); CREATE INDEX idx_doc_number ON drawings(document_number); ``` ### 10.2 Connection Pooling ```typescript // config/database.config.ts export default { type: 'mariadb', host: process.env.DB_HOST, port: parseInt(process.env.DB_PORT), database: process.env.DB_NAME, // Connection pool settings extra: { connectionLimit: 20, // Max connections queueLimit: 0, // Unlimited queue waitForConnections: true, acquireTimeout: 30000, // 30s timeout idleTimeout: 10000, // 10s idle timeout maxIdle: 5, // Max idle connections }, }; ``` ### 10.3 Redis Optimization ```typescript // config/redis.config.ts export default { cluster: [ { host: 'redis-1', port: 6379 }, { host: 'redis-2', port: 6379 }, { host: 'redis-3', port: 6379 }, ], options: { maxRetriesPerRequest: 3, enableReadyCheck: true, maxLoadingRetryTime: 10000, lazyConnect: false, // Connection pool maxRedirections: 16, retryDelayOnFailover: 100, retryDelayOnClusterDown: 300, // Performance enableOfflineQueue: true, connectTimeout: 10000, keepAlive: 30000, }, }; ``` ### 10.4 Caching Strategy ```typescript // Cache configuration @Cacheable({ ttl: 3600, // 1 hour key: 'numbering:config:{documentType}', compress: true, }) async getConfig(documentType: string) { return await this.configRepo.findOne({ where: { document_type: documentType }, }); } // Cache invalidation @CacheEvict({ key: 'numbering:config:{documentType}', }) async updateConfig(documentType: string, data: any) { return await this.configRepo.update( { document_type: documentType }, data, ); } ``` --- ## 11. Security Considerations ### 11.1 Access Control ```typescript // guards/manual-override.guard.ts @Injectable() export class ManualOverrideGuard implements CanActivate { constructor(private caslAbility: CaslAbilityFactory) {} canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const user = request.user; // Check permission const ability = this.caslAbility.createForUser(user); return ability.can('manual_override', 'DocumentNumber'); } } // Usage @Post('manual') @UseGuards(JwtAuthGuard, ManualOverrideGuard) async manualOverride(@Body() dto: ManualOverrideDto) { // Only admins can access this } ``` ### 11.2 Rate Limiting ```typescript // Apply rate limit to prevent abuse @Throttle(100, 60) // 100 requests per minute @Post('reserve') async reserve(@Body() dto: ReserveNumberDto) { // ... } ``` ### 11.3 Audit Logging ```typescript // decorators/audit-numbering.decorator.ts export function AuditNumbering(operation: string) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const result = await originalMethod.apply(this, args); // Log to audit await this.auditService.log({ operation, timestamp: new Date(), user_id: args[0]?.user?.id, metadata: { args }, }); return result; }; return descriptor; }; } // Usage @AuditNumbering('MANUAL_OVERRIDE') async manualOverride(dto: ManualOverrideDto, user: User) { // Automatically logged } ``` --- ## 12. Migration Scripts ### 12.1 Import Legacy Documents ```typescript // scripts/import-legacy-numbers.ts import { DataSource } from 'typeorm'; import * as csv from 'csv-parser'; import * as fs from 'fs'; async function importLegacyNumbers() { const dataSource = await createDataSource(); const results = []; // Read CSV fs.createReadStream('legacy-documents.csv') .pipe(csv()) .on('data', (row) => results.push(row)) .on('end', async () => { console.log(`Found ${results.length} legacy documents`); let success = 0; let failed = 0; for (const row of results) { try { await dataSource.transaction(async (manager) => { // 1. Create document await manager.insert('correspondences', { document_number: row.document_number, title: row.title, created_at: row.created_at, is_imported: true, }); // 2. Log audit await manager.insert('document_numbering_audit_logs', { operation: 'MANUAL_OVERRIDE', document_type: 'COR', document_number: row.document_number, metadata: { imported: true, source: 'legacy' }, }); success++; }); } catch (error) { console.error(`Failed to import ${row.document_number}:`, error); failed++; } } console.log(`Import complete: ${success} success, ${failed} failed`); // 3. Update sequence counters await updateSequenceCounters(dataSource); }); } async function updateSequenceCounters(dataSource: DataSource) { const result = await dataSource.query(` SELECT MAX(CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED)) as max_seq FROM correspondences WHERE document_number LIKE 'COR-2025-%' `); const maxSeq = result[0].max_seq; await dataSource.query(` UPDATE document_numbering_sequences SET current_value = ? WHERE config_id = ( SELECT id FROM document_numbering_configs WHERE document_type = 'COR' ) `, [maxSeq]); console.log(`Updated COR sequence to ${maxSeq}`); } importLegacyNumbers().catch(console.error); ``` --- ## 13. CLI Tools ### 13.1 Status Command ```typescript // cli/commands/numbering-status.command.ts import { Command, CommandRunner } from 'nest-commander'; import { SequenceService } from '../../modules/document-numbering/services'; @Command({ name: 'numbering:status', arguments: '[documentType]', options: { isDefault: false }, }) export class NumberingStatusCommand extends CommandRunner { constructor(private sequenceService: SequenceService) { super(); } async run(inputs: string[], options: any): Promise { const [documentType] = inputs; if (documentType) { await this.showTypeStatus(documentType); } else { await this.showAllStatus(); } } private async showTypeStatus(documentType: string) { const config = await this.sequenceService.getConfig(documentType); const sequence = await this.sequenceService.getSequence(documentType); console.log(`\n📊 Status for ${documentType}:\n`); console.log(`Format: ${config.format}`); console.log(`Current Value: ${sequence.current_value}`); console.log(`Max Value: ${config.max_value}`); console.log(`Utilization: ${(sequence.current_value / config.max_value * 100).toFixed(2)}%`); console.log(`Last Used: ${sequence.last_used_at}`); console.log(`Manual Override: ${config.allow_manual_override ? 'Yes' : 'No'}`); } private async showAllStatus() { const configs = await this.sequenceService.getAllConfigs(); console.log('\n📊 Document Numbering Status:\n'); console.table( configs.map((c) => ({ Type: c.document_type, Current: c.sequence?.current_value || 0, Max: c.max_value, 'Utilization (%)': ((c.sequence?.current_value || 0) / c.max_value * 100).toFixed(2), 'Last Used': c.sequence?.last_used_at || 'Never', })), ); } } ``` --- ## 14. Best Practices Summary ### 14.1 DO's ✅ - ✅ Always use two-phase commit (reserve + confirm) - ✅ Implement fallback to DB-only if Redis fails - ✅ Log every operation to audit trail - ✅ Monitor sequence utilization (alert at 90%) - ✅ Test under concurrent load (1000+ req/s) - ✅ Use pessimistic locking in database - ✅ Set reasonable TTL for reservations (5 min) - ✅ Validate manual override format - ✅ Skip cancelled numbers (never reuse) - ✅ Implement exponential backoff on retry ### 14.2 DON'Ts ❌ - ❌ Never skip validation for manual override - ❌ Never reuse cancelled numbers - ❌ Never trust client-generated numbers - ❌ Never increase sequence without transaction - ❌ Never ignore lock acquisition failures - ❌ Never deploy without load testing - ❌ Never extend max_value without planning - ❌ Never modify sequence table directly - ❌ Never skip audit logging - ❌ Never assume Redis is always available --- ## 15. Appendix ### 15.1 Error Codes ```typescript export enum NumberingErrorCode { CONFIG_NOT_FOUND = 'NB001', SEQUENCE_EXHAUSTED = 'NB002', LOCK_TIMEOUT = 'NB003', RESERVATION_EXPIRED = 'NB004', DUPLICATE_NUMBER = 'NB005', INVALID_FORMAT = 'NB006', MANUAL_OVERRIDE_NOT_ALLOWED = 'NB007', REDIS_UNAVAILABLE = 'NB008', } ``` ### 15.2 Environment Variables ```bash # Redis Configuration REDIS_HOST=localhost REDIS_PORT=6379 REDIS_CLUSTER_NODES=redis-1:6379,redis-2:6379,redis-3:6379 # Numbering Configuration NUMBERING_LOCK_TIMEOUT=5000 # 5 seconds NUMBERING_RESERVATION_TTL=300 # 5 minutes NUMBERING_RETRY_ATTEMPTS=3 NUMBERING_RETRY_DELAY=200 # milliseconds # Monitoring PROMETHEUS_PORT=9090 GRAFANA_PORT=3000 ``` ### 15.3 Useful Queries ```sql -- Find next available number SELECT MAX(CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED)) + 1 FROM correspondences WHERE document_number LIKE 'COR-2025-%'; -- Check for gaps in sequence SELECT t1.seq + 1 AS gap_start FROM ( SELECT CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED) AS seq FROM correspondences WHERE document_number LIKE 'COR-2025-%' ) t1 LEFT JOIN ( SELECT CAST(SUBSTRING_INDEX(document_number, '-', -1) AS UNSIGNED) AS seq FROM correspondences WHERE document_number LIKE 'COR-2025-%' ) t2 ON t1.seq + 1 = t2.seq WHERE t2.seq IS NULL ORDER BY gap_start; -- Audit trail for specific number SELECT * FROM document_numbering_audit_logs WHERE document_number = 'COR-2025-00042' ORDER BY timestamp DESC; ``` --- **Document Prepared By**: Backend Team **Last Review**: 2025-01-16 **Next Review**: 2025-04-16