251206:1400 version 1.5.1
This commit is contained in:
@@ -6,6 +6,8 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { DocumentNumberingService } from './document-numbering.service';
|
||||
import { DocumentNumberFormat } from './entities/document-number-format.entity';
|
||||
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
|
||||
import { DocumentNumberAudit } from './entities/document-number-audit.entity'; // [P0-4]
|
||||
import { DocumentNumberError } from './entities/document-number-error.entity'; // [P0-4]
|
||||
|
||||
// Master Entities ที่ต้องใช้ Lookup
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
@@ -20,6 +22,8 @@ import { CorrespondenceSubType } from '../correspondence/entities/correspondence
|
||||
TypeOrmModule.forFeature([
|
||||
DocumentNumberFormat,
|
||||
DocumentNumberCounter,
|
||||
DocumentNumberAudit, // [P0-4]
|
||||
DocumentNumberError, // [P0-4]
|
||||
Project,
|
||||
Organization,
|
||||
CorrespondenceType,
|
||||
|
||||
@@ -25,6 +25,8 @@ import { Organization } from '../project/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
|
||||
import { DocumentNumberAudit } from './entities/document-number-audit.entity'; // [P0-4]
|
||||
import { DocumentNumberError } from './entities/document-number-error.entity'; // [P0-4]
|
||||
|
||||
// Interfaces
|
||||
import {
|
||||
@@ -53,8 +55,12 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
private disciplineRepo: Repository<Discipline>,
|
||||
@InjectRepository(CorrespondenceSubType)
|
||||
private subTypeRepo: Repository<CorrespondenceSubType>,
|
||||
@InjectRepository(DocumentNumberAudit) // [P0-4]
|
||||
private auditRepo: Repository<DocumentNumberAudit>,
|
||||
@InjectRepository(DocumentNumberError) // [P0-4]
|
||||
private errorRepo: Repository<DocumentNumberError>,
|
||||
|
||||
private configService: ConfigService,
|
||||
private configService: ConfigService
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -74,7 +80,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Document Numbering Service initialized (Redis: ${host}:${port})`,
|
||||
`Document Numbering Service initialized (Redis: ${host}:${port})`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
// 2. ดึง Format Template
|
||||
const formatTemplate = await this.getFormatTemplate(
|
||||
ctx.projectId,
|
||||
ctx.typeId,
|
||||
ctx.typeId
|
||||
);
|
||||
|
||||
// 3. สร้าง Resource Key สำหรับ Lock (ละเอียดถึงระดับ Discipline)
|
||||
@@ -142,12 +148,30 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.counterRepo.save(counter);
|
||||
|
||||
// E. Format Result
|
||||
return this.replaceTokens(formatTemplate, tokens, counter.lastNumber);
|
||||
const generatedNumber = this.replaceTokens(
|
||||
formatTemplate,
|
||||
tokens,
|
||||
counter.lastNumber
|
||||
);
|
||||
|
||||
// [P0-4] F. Audit Logging
|
||||
await this.logAudit({
|
||||
generatedNumber,
|
||||
counterKey: resourceKey,
|
||||
templateUsed: formatTemplate,
|
||||
sequenceNumber: counter.lastNumber,
|
||||
userId: ctx.userId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
retryCount: i,
|
||||
lockWaitMs: 0, // TODO: calculate actual wait time
|
||||
});
|
||||
|
||||
return generatedNumber;
|
||||
} catch (err) {
|
||||
// ถ้า Version ไม่ตรง (มีคนแทรกได้ในเสี้ยววินาที) ให้ Retry
|
||||
if (err instanceof OptimisticLockVersionMismatchError) {
|
||||
this.logger.warn(
|
||||
`Optimistic Lock Collision for ${resourceKey}. Retrying...`,
|
||||
`Optimistic Lock Collision for ${resourceKey}. Retrying...`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -156,10 +180,22 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(
|
||||
'Failed to generate document number after retries.',
|
||||
'Failed to generate document number after retries.'
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error generating number for ${resourceKey}`, error);
|
||||
|
||||
// [P0-4] Log error
|
||||
await this.logError({
|
||||
counterKey: resourceKey,
|
||||
errorType: this.classifyError(error),
|
||||
errorMessage: error.message,
|
||||
stackTrace: error.stack,
|
||||
userId: ctx.userId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
context: ctx,
|
||||
}).catch(() => {}); // Don't throw if error logging fails
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
// 🔓 Release Lock
|
||||
@@ -174,7 +210,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
private async resolveTokens(
|
||||
ctx: GenerateNumberContext,
|
||||
year: number,
|
||||
year: number
|
||||
): Promise<DecodedTokens> {
|
||||
const [project, org, type] = await Promise.all([
|
||||
this.projectRepo.findOne({ where: { id: ctx.projectId } }),
|
||||
@@ -210,6 +246,17 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
// ใน Req 6B ตัวอย่างใช้ 2568 (พ.ศ.) ดังนั้นต้องแปลง
|
||||
const yearTh = (year + 543).toString();
|
||||
|
||||
// [P1-4] Resolve recipient organization
|
||||
let recipientCode = '';
|
||||
if (ctx.recipientOrgId) {
|
||||
const recipient = await this.orgRepo.findOne({
|
||||
where: { id: ctx.recipientOrgId },
|
||||
});
|
||||
if (recipient) {
|
||||
recipientCode = recipient.organizationCode;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectCode: project.projectCode,
|
||||
orgCode: org.organizationCode,
|
||||
@@ -219,6 +266,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
subTypeNumber,
|
||||
year: yearTh,
|
||||
yearShort: yearTh.slice(-2), // 68
|
||||
recipientCode, // [P1-4]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +275,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
private async getFormatTemplate(
|
||||
projectId: number,
|
||||
typeId: number,
|
||||
typeId: number
|
||||
): Promise<string> {
|
||||
const format = await this.formatRepo.findOne({
|
||||
where: { projectId, correspondenceTypeId: typeId },
|
||||
@@ -242,7 +290,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
private replaceTokens(
|
||||
template: string,
|
||||
tokens: DecodedTokens,
|
||||
seq: number,
|
||||
seq: number
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
@@ -253,6 +301,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
'{DISCIPLINE}': tokens.disciplineCode,
|
||||
'{SUBTYPE}': tokens.subTypeCode,
|
||||
'{SUBTYPE_NUM}': tokens.subTypeNumber, // [Req 6B] For Transmittal/RFA
|
||||
'{RECIPIENT}': tokens.recipientCode, // [P1-4] Recipient organization
|
||||
'{YEAR}': tokens.year,
|
||||
'{YEAR_SHORT}': tokens.yearShort,
|
||||
};
|
||||
@@ -271,4 +320,50 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* [P0-4] Log successful number generation to audit table
|
||||
*/
|
||||
private async logAudit(
|
||||
auditData: Partial<DocumentNumberAudit>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.auditRepo.save(auditData);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to log audit', error);
|
||||
// Don't throw - audit failure shouldn't block number generation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [P0-4] Log error to error table
|
||||
*/
|
||||
private async logError(
|
||||
errorData: Partial<DocumentNumberError>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.errorRepo.save(errorData);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to log error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [P0-4] Classify error type for logging
|
||||
*/
|
||||
private classifyError(error: any): string {
|
||||
if (error.message?.includes('lock') || error.message?.includes('Lock')) {
|
||||
return 'LOCK_TIMEOUT';
|
||||
}
|
||||
if (error instanceof OptimisticLockVersionMismatchError) {
|
||||
return 'VERSION_CONFLICT';
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||
return 'REDIS_ERROR';
|
||||
}
|
||||
if (error.name === 'QueryFailedError') {
|
||||
return 'DB_ERROR';
|
||||
}
|
||||
return 'VALIDATION_ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('document_number_audit')
|
||||
@Index(['generatedAt'])
|
||||
@Index(['userId'])
|
||||
export class DocumentNumberAudit {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'generated_number', length: 100 })
|
||||
generatedNumber!: string;
|
||||
|
||||
@Column({ name: 'counter_key', length: 255 })
|
||||
counterKey!: string;
|
||||
|
||||
@Column({ name: 'template_used', type: 'text' })
|
||||
templateUsed!: string;
|
||||
|
||||
@Column({ name: 'sequence_number' })
|
||||
sequenceNumber!: number;
|
||||
|
||||
@Column({ name: 'user_id', nullable: true })
|
||||
userId?: number;
|
||||
|
||||
@Column({ name: 'ip_address', length: 45, nullable: true })
|
||||
ipAddress?: string;
|
||||
|
||||
@Column({ name: 'retry_count', default: 0 })
|
||||
retryCount!: number;
|
||||
|
||||
@Column({ name: 'lock_wait_ms', nullable: true })
|
||||
lockWaitMs?: number;
|
||||
|
||||
@CreateDateColumn({ name: 'generated_at' })
|
||||
generatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('document_number_errors')
|
||||
@Index(['errorAt'])
|
||||
@Index(['userId'])
|
||||
export class DocumentNumberError {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'counter_key', length: 255 })
|
||||
counterKey!: string;
|
||||
|
||||
@Column({ name: 'error_type', length: 50 })
|
||||
errorType!: string;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text' })
|
||||
errorMessage!: string;
|
||||
|
||||
@Column({ name: 'stack_trace', type: 'text', nullable: true })
|
||||
stackTrace?: string;
|
||||
|
||||
@Column({ name: 'user_id', nullable: true })
|
||||
userId?: number;
|
||||
|
||||
@Column({ name: 'ip_address', length: 45, nullable: true })
|
||||
ipAddress?: string;
|
||||
|
||||
@Column({ name: 'context', type: 'json', nullable: true })
|
||||
context?: any;
|
||||
|
||||
@CreateDateColumn({ name: 'error_at' })
|
||||
errorAt!: Date;
|
||||
}
|
||||
@@ -8,6 +8,13 @@ export interface GenerateNumberContext {
|
||||
disciplineId?: number; // (Optional) Discipline ID (สาขางาน)
|
||||
year?: number; // (Optional) ถ้าไม่ส่งจะใช้ปีปัจจุบัน
|
||||
|
||||
// [P1-4] Recipient organization for {RECIPIENT} token
|
||||
recipientOrgId?: number; // Primary recipient organization
|
||||
|
||||
// [P0-4] Audit tracking fields
|
||||
userId?: number; // User requesting the number
|
||||
ipAddress?: string; // IP address of the requester
|
||||
|
||||
// สำหรับกรณีพิเศษที่ต้องการ Override ค่าบางอย่าง
|
||||
customTokens?: Record<string, string>;
|
||||
}
|
||||
@@ -21,4 +28,5 @@ export interface DecodedTokens {
|
||||
subTypeNumber: string;
|
||||
year: string;
|
||||
yearShort: string;
|
||||
recipientCode: string; // [P1-4] Recipient organization code
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user