260322:1648 Correct Coresspondence / Doing RFA / Correct CI
CI Pipeline / build (push) Failing after 12m41s
Build and Deploy / deploy (push) Failing after 2m44s

This commit is contained in:
admin
2026-03-22 16:48:12 +07:00
parent e5deedb42e
commit 11984bfa29
683 changed files with 105251 additions and 29068 deletions
@@ -54,14 +54,14 @@
-**General Correspondence** (LETTER / MEMO / etc.) → **Project Level Scope**
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)`
- *Note*: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
- _Note_: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
-**Transmittal****Project Level Scope** with Sub-Type lookup
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, sub_type_id, 0, 0, year)`
-**RFA****Contract Level Scope** (Implicit)
- Counter Key: `(project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, discipline_id, year)`
- *Mechanism*: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
- _Mechanism_: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
### 4. Error Handling
@@ -97,38 +97,38 @@ import { CorrespondenceType } from '../../correspondence-type/entities/correspon
@Entity('document_number_formats')
export class DocumentNumberFormat {
@PrimaryGeneratedColumn()
id: number;
@PrimaryGeneratedColumn()
id: number;
@Column()
project_id: number;
@Column()
project_id: number;
@Column({ name: 'correspondence_type_id' })
correspondenceTypeId: number;
@Column({ name: 'correspondence_type_id' })
correspondenceTypeId: number;
// Note: Schema currently only has project_id + correspondence_type_id.
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
// Note: Schema currently only has project_id + correspondence_type_id.
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
formatTemplate: string;
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
formatTemplate: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@UpdateDateColumn()
updated_at: Date;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType: CorrespondenceType;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType: CorrespondenceType;
}
#### 1.2 Document Number Counter Entity
@@ -160,16 +160,16 @@ export class DocumentNumberCounter {
correspondenceTypeId: number;
@PrimaryColumn({ name: 'sub_type_id', default: 0 })
subTypeId: number; // for TRANSMITTAL only
subTypeId: number; // for TRANSMITTAL only
@PrimaryColumn({ name: 'rfa_type_id', default: 0 })
rfaTypeId: number; // for RFA only
rfaTypeId: number; // for RFA only
@PrimaryColumn({ name: 'discipline_id', default: 0 })
disciplineId: number; // for RFA only
disciplineId: number; // for RFA only
@PrimaryColumn({ name: 'current_year' })
currentYear: number; // ปี ค.ศ.
currentYear: number; // ปี ค.ศ.
@Column({ name: 'last_number', default: 0 })
lastNumber: number;
@@ -298,7 +298,14 @@ export class GenerateNumberDto {
```typescript
// File: backend/src/modules/document-numbering/document-numbering.service.ts
import { Injectable, Logger, ConflictException, ServiceUnavailableException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
import {
Injectable,
Logger,
ConflictException,
ServiceUnavailableException,
NotFoundException,
InternalServerErrorException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import Redlock from 'redlock';
@@ -323,7 +330,7 @@ export class DocumentNumberingService {
private dataSource: DataSource,
private redis: Redis,
private redlock: Redlock,
private metricsService: MetricsService,
private metricsService: MetricsService
) {}
/**
@@ -337,31 +344,17 @@ export class DocumentNumberingService {
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 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,
);
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,
),
async () => await this.generateNumberWithLock(lockKey, dto, year, subTypeId, disciplineId, startTime),
5, // Max 5 retries for lock acquisition
1000, // Initial delay 1s
1000 // Initial delay 1s
);
// Track metrics
@@ -390,7 +383,7 @@ export class DocumentNumberingService {
year: number,
subTypeId: number,
disciplineId: number,
startTime: number,
startTime: number
): Promise<string> {
let lock: any;
const lockStartTime = Date.now();
@@ -491,7 +484,6 @@ export class DocumentNumberingService {
this.logger.log(`Generated: ${formattedNumber} (lock wait: ${lockWaitMs}ms, total: ${Date.now() - startTime}ms)`);
return formattedNumber;
} finally {
// Step 6: Release Redis lock
if (lock) {
@@ -511,7 +503,7 @@ export class DocumentNumberingService {
dto: GenerateNumberDto,
year: number,
subTypeId: number,
disciplineId: number,
disciplineId: number
): Promise<string> {
return await this.dataSource.transaction(async (manager) => {
// Pessimistic lock: SELECT ... FOR UPDATE
@@ -532,16 +524,20 @@ export class DocumentNumberingService {
// 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,
});
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,
@@ -616,36 +612,28 @@ export class DocumentNumberingService {
* Retry with exponential backoff
* Scenarios 2, 3, 4
*/
private async retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number,
initialDelay: number,
): Promise<T> {
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.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 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 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,
@@ -659,10 +647,7 @@ export class DocumentNumberingService {
/**
* Get configuration template (Format)
*/
private async getConfig(
projectId: number,
correspondenceTypeId: number,
): Promise<DocumentNumberFormat> {
private async getConfig(projectId: number, correspondenceTypeId: number): Promise<DocumentNumberFormat> {
// Note: Schema currently only separates by project_id and correspondence_type_id
// If we need sub-type specific templates, we should check if they are supported in the future schema.
// Converting old logic slightly to match v1.5.1 schema columns.
@@ -720,7 +705,7 @@ export class DocumentNumberingService {
}
private buildLockKey(...parts: Array<number | string | null | undefined>): string {
return `doc_num:${parts.filter(p => p !== null && p !== undefined).join(':')}`;
return `doc_num:${parts.filter((p) => p !== null && p !== undefined).join(':')}`;
}
}
```
@@ -755,10 +740,7 @@ export class DocumentNumberingController {
@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 }> {
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;
@@ -812,7 +794,7 @@ import Redis from 'ioredis';
export class RateLimitGuard implements CanActivate {
constructor(
private redis: Redis,
private reflector: Reflector,
private reflector: Reflector
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -830,10 +812,7 @@ export class RateLimitGuard implements CanActivate {
}
if (userCount > 10) {
throw new HttpException(
'Rate limit exceeded: 10 requests per minute per user',
HttpStatus.TOO_MANY_REQUESTS,
);
throw new HttpException('Rate limit exceeded: 10 requests per minute per user', HttpStatus.TOO_MANY_REQUESTS);
}
}
@@ -846,10 +825,7 @@ export class RateLimitGuard implements CanActivate {
}
if (ipCount > 50) {
throw new HttpException(
'Rate limit exceeded: 50 requests per minute per IP',
HttpStatus.TOO_MANY_REQUESTS,
);
throw new HttpException('Rate limit exceeded: 50 requests per minute per IP', HttpStatus.TOO_MANY_REQUESTS);
}
return true;
@@ -876,11 +852,7 @@ import { MetricsModule } from '../metrics/metrics.module';
@Module({
imports: [
TypeOrmModule.forFeature([
DocumentNumberCounter,
DocumentNumberConfig,
DocumentNumberAudit,
]),
TypeOrmModule.forFeature([DocumentNumberCounter, DocumentNumberConfig, DocumentNumberAudit]),
RedisModule,
MetricsModule,
],
@@ -934,7 +906,7 @@ describe('DocumentNumberingService - Concurrency', () => {
expect(unique.size).toBe(100);
// Check format
results.forEach(num => {
results.forEach((num) => {
expect(num).toMatch(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
});
@@ -972,9 +944,7 @@ describe('DocumentNumberingService - Error Scenarios', () => {
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);
await expect(service.generateNextNumber(dto)).rejects.toThrow(ServiceUnavailableException);
expect(metricsService.incrementCounter).toHaveBeenCalledWith('docnum.retry', expect.any(Object));
});
@@ -1089,7 +1059,9 @@ describe('DocumentNumberingService - Formats', () => {
describe('RateLimitGuard', () => {
it('should block after 10 requests per user per minute', async () => {
const dto = { /* ... */ };
const dto = {
/* ... */
};
// Make 10 successful requests
for (let i = 0; i < 10; i++) {
@@ -1097,9 +1069,7 @@ describe('RateLimitGuard', () => {
}
// 11th request should fail
await expect(service.generateNextNumber(dto))
.rejects
.toThrow('Rate limit exceeded');
await expect(service.generateNextNumber(dto)).rejects.toThrow('Rate limit exceeded');
});
it('should block after 50 requests per IP per minute', async () => {
@@ -1168,10 +1138,10 @@ expect:
- contentType: json
ensure:
p50: 500 # 50th percentile < 500ms
p95: 2000 # 95th percentile < 2s
p99: 5000 # 99th percentile < 5s
maxErrorRate: 0.001 # < 0.1% errors
p50: 500 # 50th percentile < 500ms
p95: 2000 # 95th percentile < 2s
p99: 5000 # 99th percentile < 5s
maxErrorRate: 0.001 # < 0.1% errors
```
---