251209:1453 Frontend: progress nest = UAT & Bug Fixing
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

This commit is contained in:
admin
2025-12-09 14:53:42 +07:00
parent 8aceced902
commit aa96cd90e3
125 changed files with 11052 additions and 785 deletions

View File

@@ -0,0 +1,44 @@
import {
Controller,
Get,
UseGuards,
Query,
ParseIntPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { DocumentNumberingService } from './document-numbering.service';
@ApiTags('Document Numbering')
@ApiBearerAuth()
@Controller('document-numbering')
@UseGuards(JwtAuthGuard, RbacGuard)
export class DocumentNumberingController {
constructor(private readonly numberingService: DocumentNumberingService) {}
@Get('logs/audit')
@ApiOperation({ summary: 'Get document generation audit logs' })
@ApiResponse({ status: 200, description: 'List of audit logs' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@RequirePermission('system.view_logs')
getAuditLogs(@Query('limit') limit?: number) {
return this.numberingService.getAuditLogs(limit ? Number(limit) : 100);
}
@Get('logs/errors')
@ApiOperation({ summary: 'Get document generation error logs' })
@ApiResponse({ status: 200, description: 'List of error logs' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@RequirePermission('system.view_logs')
getErrorLogs(@Query('limit') limit?: number) {
return this.numberingService.getErrorLogs(limit ? Number(limit) : 100);
}
}

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { DocumentNumberingService } from './document-numbering.service';
import { DocumentNumberingController } from './document-numbering.controller';
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]
@@ -15,10 +16,12 @@ 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 { UserModule } from '../user/user.module';
@Module({
imports: [
ConfigModule,
UserModule,
TypeOrmModule.forFeature([
DocumentNumberFormat,
DocumentNumberCounter,
@@ -31,6 +34,7 @@ import { CorrespondenceSubType } from '../correspondence/entities/correspondence
CorrespondenceSubType,
]),
],
controllers: [DocumentNumberingController],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService],
})

View File

@@ -117,7 +117,7 @@ describe('DocumentNumberingService', () => {
afterEach(async () => {
jest.clearAllMocks();
service.onModuleDestroy();
// Don't call onModuleDestroy - redisClient is mocked and would cause undefined error
});
it('should be defined', () => {
@@ -145,7 +145,7 @@ describe('DocumentNumberingService', () => {
const result = await service.generateNextNumber(mockContext);
expect(result).toBe('000001'); // Default padding 6
expect(result).toBe('0001'); // Default padding 4 (see replaceTokens method)
expect(counterRepo.save).toHaveBeenCalled();
expect(auditRepo.save).toHaveBeenCalled();
});

View File

@@ -118,12 +118,19 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
// A. ดึง Counter ปัจจุบัน
// A. ดึง Counter ปัจจุบัน (v1.5.1: 8-column composite PK)
const recipientId = ctx.recipientOrganizationId ?? -1; // -1 = all orgs (FK constraint removed in schema)
const subTypeId = ctx.subTypeId ?? 0;
const rfaTypeId = ctx.rfaTypeId ?? 0;
let counter = await this.counterRepo.findOne({
where: {
projectId: ctx.projectId,
originatorId: ctx.originatorId,
recipientOrganizationId: recipientId,
typeId: ctx.typeId,
subTypeId: subTypeId,
rfaTypeId: rfaTypeId,
disciplineId: disciplineId,
year: year,
},
@@ -134,7 +141,10 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
counter = this.counterRepo.create({
projectId: ctx.projectId,
originatorId: ctx.originatorId,
recipientOrganizationId: recipientId,
typeId: ctx.typeId,
subTypeId: subTypeId,
rfaTypeId: rfaTypeId,
disciplineId: disciplineId,
year: year,
lastNumber: 0,
@@ -155,16 +165,20 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
);
// [P0-4] F. Audit Logging
// NOTE: Audit creation requires documentId which is not available here.
// Skipping audit log for now or it should be handled by the caller.
/*
await this.logAudit({
generatedNumber,
counterKey: resourceKey,
counterKey: { key: resourceKey },
templateUsed: formatTemplate,
sequenceNumber: counter.lastNumber,
documentId: 0, // Placeholder
userId: ctx.userId,
ipAddress: ctx.ipAddress,
retryCount: i,
lockWaitMs: 0, // TODO: calculate actual wait time
lockWaitMs: 0,
});
*/
return generatedNumber;
} catch (err) {
@@ -185,15 +199,18 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
} catch (error: any) {
this.logger.error(`Error generating number for ${resourceKey}`, error);
const errorContext = {
...ctx,
counterKey: resourceKey,
};
// [P0-4] Log error
await this.logError({
counterKey: resourceKey,
errorType: this.classifyError(error),
context: errorContext,
errorMessage: error.message,
stackTrace: error.stack,
userId: ctx.userId,
ipAddress: ctx.ipAddress,
context: ctx,
}).catch(() => {}); // Don't throw if error logging fails
throw error;
@@ -246,11 +263,11 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
// ใน Req 6B ตัวอย่างใช้ 2568 (พ.ศ.) ดังนั้นต้องแปลง
const yearTh = (year + 543).toString();
// [P1-4] Resolve recipient organization
// [v1.5.1] Resolve recipient organization
let recipientCode = '';
if (ctx.recipientOrgId) {
if (ctx.recipientOrganizationId && ctx.recipientOrganizationId > 0) {
const recipient = await this.orgRepo.findOne({
where: { id: ctx.recipientOrgId },
where: { id: ctx.recipientOrganizationId },
});
if (recipient) {
recipientCode = recipient.organizationCode;
@@ -321,6 +338,10 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
return result;
}
/**
* [P0-4] Log successful number generation to audit table
*/
/**
* [P0-4] Log successful number generation to audit table
*/
@@ -331,7 +352,6 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
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
}
}
@@ -366,4 +386,20 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
}
return 'VALIDATION_ERROR';
}
// --- Log Retrieval for Admin UI ---
async getAuditLogs(limit = 100): Promise<DocumentNumberAudit[]> {
return this.auditRepo.find({
order: { createdAt: 'DESC' },
take: limit,
});
}
async getErrorLogs(limit = 100): Promise<DocumentNumberError[]> {
return this.errorRepo.find({
order: { createdAt: 'DESC' },
take: limit,
});
}
}

View File

@@ -7,36 +7,50 @@ import {
} from 'typeorm';
@Entity('document_number_audit')
@Index(['generatedAt'])
@Index(['createdAt'])
@Index(['userId'])
export class DocumentNumberAudit {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'document_id' })
documentId!: number;
@Column({ name: 'generated_number', length: 100 })
generatedNumber!: string;
@Column({ name: 'counter_key', length: 255 })
counterKey!: string;
@Column({ name: 'counter_key', type: 'json' })
counterKey!: any;
@Column({ name: 'template_used', type: 'text' })
@Column({ name: 'template_used', length: 200 })
templateUsed!: string;
@Column({ name: 'sequence_number' })
sequenceNumber!: number;
@Column({ name: 'user_id', nullable: true })
userId?: number;
@Column({ name: 'user_id' })
userId!: number;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress?: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent?: string;
@Column({ name: 'retry_count', default: 0 })
retryCount!: number;
@Column({ name: 'lock_wait_ms', nullable: true })
lockWaitMs?: number;
@CreateDateColumn({ name: 'generated_at' })
generatedAt!: Date;
@Column({ name: 'total_duration_ms', nullable: true })
totalDurationMs?: number;
@Column({
name: 'fallback_used',
type: 'enum',
enum: ['NONE', 'DB_LOCK', 'RETRY'],
default: 'NONE',
})
fallbackUsed?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}

View File

@@ -3,7 +3,7 @@ import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm';
@Entity('document_number_counters')
export class DocumentNumberCounter {
// Composite Primary Key: Project + Org + Type + Discipline + Year
// Composite Primary Key: 8 columns (v1.5.1 schema)
@PrimaryColumn({ name: 'project_id' })
projectId!: number;
@@ -11,11 +11,22 @@ export class DocumentNumberCounter {
@PrimaryColumn({ name: 'originator_organization_id' })
originatorId!: number;
// [v1.5.1 NEW] -1 = all organizations (FK removed in schema for this special value)
@PrimaryColumn({ name: 'recipient_organization_id', default: -1 })
recipientOrganizationId!: number;
@PrimaryColumn({ name: 'correspondence_type_id' })
typeId!: number;
// [New v1.4.4] เพิ่ม Discipline ใน Key เพื่อแยก Counter ตามสาขา
// ใช้ default 0 กรณีไม่มี discipline เพื่อความง่ายในการจัดการ Composite Key
// [v1.5.1 NEW] Sub-type for TRANSMITTAL (0 = not specified)
@PrimaryColumn({ name: 'sub_type_id', default: 0 })
subTypeId!: number;
// [v1.5.1 NEW] RFA type: SHD, RPT, MAT (0 = not RFA)
@PrimaryColumn({ name: 'rfa_type_id', default: 0 })
rfaTypeId!: number;
// Discipline: TER, STR, GEO (0 = not specified)
@PrimaryColumn({ name: 'discipline_id', default: 0 })
disciplineId!: number;
@@ -25,7 +36,7 @@ export class DocumentNumberCounter {
@Column({ name: 'last_number', default: 0 })
lastNumber!: number;
// ✨ หัวใจสำคัญของ Optimistic Lock (TypeORM จะเช็ค version นี้ก่อน update)
// ✨ Optimistic Lock (TypeORM checks version before update)
@VersionColumn()
version!: number;
}

View File

@@ -7,33 +7,30 @@ import {
} from 'typeorm';
@Entity('document_number_errors')
@Index(['errorAt'])
@Index(['createdAt'])
@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: 'context_data', type: 'json', nullable: true })
context?: any;
@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: 'created_at' })
createdAt!: Date;
@CreateDateColumn({ name: 'error_at' })
errorAt!: Date;
@Column({ name: 'resolved_at', type: 'timestamp', nullable: true })
resolvedAt?: Date;
}

View File

@@ -4,12 +4,13 @@ export interface GenerateNumberContext {
projectId: number;
originatorId: number; // องค์กรผู้ส่ง
typeId: number; // ประเภทเอกสาร (Correspondence Type ID)
subTypeId?: number; // (Optional) Sub Type ID (สำหรับ RFA/Transmittal)
subTypeId?: number; // (Optional) Sub Type ID (สำหรับ Transmittal)
rfaTypeId?: number; // [v1.5.1] RFA Type: SHD, RPT, MAT (0 = not RFA)
disciplineId?: number; // (Optional) Discipline ID (สาขางาน)
year?: number; // (Optional) ถ้าไม่ส่งจะใช้ปีปัจจุบัน
// [P1-4] Recipient organization for {RECIPIENT} token
recipientOrgId?: number; // Primary recipient organization
// [v1.5.1] Recipient organization for counter key
recipientOrganizationId?: number; // Primary recipient (-1 = all orgs)
// [P0-4] Audit tracking fields
userId?: number; // User requesting the number