diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index bce5753c..2fc4e03c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -57,6 +57,7 @@ import { ResponseCodeModule } from './modules/response-code/response-code.module import { DelegationModule } from './modules/delegation/delegation.module'; import { ReminderModule } from './modules/reminder/reminder.module'; import { DistributionModule } from './modules/distribution/distribution.module'; +import { TagsModule } from './modules/tags/tags.module'; @Module({ imports: [ @@ -197,6 +198,7 @@ import { DistributionModule } from './modules/distribution/distribution.module'; DelegationModule, ReminderModule, DistributionModule, + TagsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/common/auth/casl/ability.factory.ts b/backend/src/common/auth/casl/ability.factory.ts index 6cd37980..72d1458b 100644 --- a/backend/src/common/auth/casl/ability.factory.ts +++ b/backend/src/common/auth/casl/ability.factory.ts @@ -4,7 +4,13 @@ import { User } from '../../../modules/user/entities/user.entity'; import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity'; // Define action types -export type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage'; +export type Actions = + | 'create' + | 'read' + | 'update' + | 'delete' + | 'manage' + | 'commit'; // Define subject types (resources) export type Subjects = @@ -18,6 +24,7 @@ export type Subjects = | 'user' | 'role' | 'workflow' + | 'migration' | 'all'; export type AppAbility = Ability<[Actions, Subjects]>; diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts index f3d7cce3..e6aa7ce7 100644 --- a/backend/src/modules/ai/ai.controller.ts +++ b/backend/src/modules/ai/ai.controller.ts @@ -53,8 +53,10 @@ import { AiRagQueryDto } from './dto/ai-rag-query.dto'; import { ExtractDocumentDto } from './dto/extract-document.dto'; import { AiCallbackDto } from './dto/ai-callback.dto'; import { CreateAiJobDto } from './dto/create-ai-job.dto'; +import { SubmitAiJobDto } from './dto/submit-ai-job.dto'; import { MigrationUpdateDto } from './dto/migration-update.dto'; import { MigrationQueryDto } from './dto/migration-query.dto'; +import { ValidationException } from '../../common/exceptions'; import { ApproveLegacyMigrationDto, LegacyMigrationIngestDto, @@ -171,6 +173,43 @@ export class AiController { return this.aiService.getAiJobStatus(jobId); } + @Post('jobs') + @UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard) + @ApiBearerAuth() + @RequirePermission('ai.suggest') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: 'Submit AI migration job — ส่งงานย้ายเอกสารให้ AI ประมวลผล', + description: + 'รับ tempAttachmentId/documentNumber แล้วส่งงานย้ายเอกสารเข้า BullMQ เพื่อรอการประมวลผล', + }) + @ApiHeader({ + name: 'Idempotency-Key', + description: 'Unique key เพื่อป้องกัน duplicate AI job', + required: true, + }) + async submitMigrationJob( + @Body() dto: SubmitAiJobDto, + @Headers('idempotency-key') idempotencyKey: string + ) { + if (!idempotencyKey) { + throw new ValidationException('Idempotency-Key header is required'); + } + return this.aiService.submitMigrationJob(dto, idempotencyKey); + } + + @Get('jobs/:jobId') + @UseGuards(JwtAuthGuard, RbacGuard) + @ApiBearerAuth() + @RequirePermission('ai.suggest') + @ApiOperation({ + summary: 'AI Job Status polling by jobId', + }) + @ApiParam({ name: 'jobId', description: 'BullMQ job id' }) + async getAiJobStatusById(@Param('jobId') jobId: string) { + return this.aiService.getAiJobStatus(jobId); + } + @Post('extract') @UseGuards(JwtAuthGuard, AiEnabledGuard, RbacGuard) @ApiBearerAuth() diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 921e3630..a5b855e5 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -5,6 +5,7 @@ // - 2026-05-19: เพิ่ม IntentClassifierModule (ADR-024 Intent Classification). // - 2026-05-19: เพิ่ม AiToolModule (ADR-025 AI Tool Layer). // - 2026-05-21: ลงทะเบียน SystemSetting, AiSettingsService และ AiEnabledGuard สำหรับ ADR-027. +// - 2026-05-22: นำเข้าและลงทะเบียน CleanupTempFilesWorker (T016) เพื่อลบไฟล์แนบชั่วคราวหมดอายุ // Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023) import { Logger, Module, OnModuleInit } from '@nestjs/common'; @@ -36,7 +37,10 @@ import { SystemSetting } from './entities/system-setting.entity'; import { AiEnabledGuard } from './guards/ai-enabled.guard'; import { UserModule } from '../user/user.module'; import { MigrationModule } from '../migration/migration.module'; +import { TagsModule } from '../tags/tags.module'; import { FileStorageModule } from '../../common/file-storage/file-storage.module'; +import { ImportTransaction } from '../migration/entities/import-transaction.entity'; +import { MigrationReviewQueue } from '../migration/entities/migration-review-queue.entity'; import { AuditLogModule } from '../audit-log/audit-log.module'; import { AuditLog } from '../../common/entities/audit-log.entity'; import { Attachment } from '../../common/file-storage/entities/attachment.entity'; @@ -46,6 +50,7 @@ import { CorrespondenceType } from '../correspondence/entities/correspondence-ty import { RbacGuard } from '../../common/guards/rbac.guard'; import { IntentClassifierModule } from './intent-classifier/intent-classifier.module'; import { AiToolModule } from './tool/ai-tool.module'; +import { CleanupTempFilesWorker } from './workers/cleanup-temp-files.worker'; import { QUEUE_AI_BATCH, QUEUE_AI_INGEST, @@ -67,6 +72,8 @@ import { Project, Organization, CorrespondenceType, + ImportTransaction, + MigrationReviewQueue, ]), BullModule.registerQueue( @@ -108,6 +115,7 @@ import { // UserModule สำหรับ RbacGuard (ต้องการ UserService) UserModule, MigrationModule, + TagsModule, FileStorageModule, AuditLogModule, @@ -137,6 +145,7 @@ import { // RbacGuard ต้องการ UserService จาก UserModule RbacGuard, AiEnabledGuard, + CleanupTempFilesWorker, ], exports: [ AiService, diff --git a/backend/src/modules/ai/ai.service.spec.ts b/backend/src/modules/ai/ai.service.spec.ts index a2839cee..0bdda378 100644 --- a/backend/src/modules/ai/ai.service.spec.ts +++ b/backend/src/modules/ai/ai.service.spec.ts @@ -25,6 +25,7 @@ import { } from '../common/constants/queue.constants'; import { OllamaService } from './services/ollama.service'; import { AiQdrantService } from './qdrant.service'; +import { ImportTransaction } from '../migration/entities/import-transaction.entity'; const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken'; @@ -117,8 +118,6 @@ describe('AiService', () => { beforeEach(async () => { jest.clearAllMocks(); - - // ตั้งค่า default return values mockMigrationLogRepo.create.mockReturnValue({ publicId: '019505a1-7c3e-7000-8000-abc123def456', sourceFile: 'test-file-uuid', @@ -131,7 +130,6 @@ describe('AiService', () => { mockAuditLogRepo.save.mockResolvedValue({}); mockMainAuditLogRepo.create.mockReturnValue({}); mockMainAuditLogRepo.save.mockResolvedValue({}); - const module: TestingModule = await Test.createTestingModule({ providers: [ AiService, @@ -144,6 +142,10 @@ describe('AiService', () => { provide: getRepositoryToken(AuditLog), useValue: mockMainAuditLogRepo, }, + { + provide: getRepositoryToken(ImportTransaction), + useValue: { findOne: jest.fn(), create: jest.fn(), save: jest.fn() }, + }, { provide: getQueueToken(QUEUE_AI_REALTIME), useValue: mockQueue }, { provide: getQueueToken(QUEUE_AI_BATCH), useValue: mockQueue }, { provide: ConfigService, useValue: mockConfigService }, @@ -154,7 +156,6 @@ describe('AiService', () => { { provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis }, ], }).compile(); - service = module.get(AiService); }); diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 2d143455..5743202d 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -19,6 +19,7 @@ import { ValidationException, SystemException, BusinessException, + ConflictException, } from '../../common/exceptions'; import { MigrationLog, @@ -32,6 +33,9 @@ import { MigrationUpdateDto } from './dto/migration-update.dto'; import { MigrationQueryDto } from './dto/migration-query.dto'; import { AiValidationService } from './ai-validation.service'; import { CreateAiJobDto } from './dto/create-ai-job.dto'; +import { SubmitAiJobDto } from './dto/submit-ai-job.dto'; +import { ImportTransaction } from '../migration/entities/import-transaction.entity'; +import { Project } from '../project/entities/project.entity'; import { QUEUE_AI_BATCH, QUEUE_AI_REALTIME, @@ -159,6 +163,8 @@ export class AiService { private readonly aiAuditLogRepo: Repository, @InjectRepository(AuditLog) private readonly auditLogRepo: Repository, + @InjectRepository(ImportTransaction) + private readonly importTransactionRepo: Repository, @Optional() @InjectQueue(QUEUE_AI_REALTIME) private readonly aiRealtimeQueue?: Queue, @@ -254,6 +260,71 @@ export class AiService { } } + /** ส่งคำขอเปิดงานประมวลผลการย้ายเอกสารของ AI (migrate-document) เข้า BullMQ */ + async submitMigrationJob( + dto: SubmitAiJobDto, + idempotencyKey: string + ): Promise { + if (!this.aiBatchQueue) { + const error = new Error('AI batch queue is not registered'); + this.logger.error('AI job queue failed', { + documentPublicId: dto.payload.tempAttachmentId, + error, + }); + return { success: false, error }; + } + const existingTx = await this.importTransactionRepo.findOne({ + where: { + documentNumber: dto.payload.documentNumber, + batchId: dto.payload.batchId, + }, + }); + if (existingTx && existingTx.statusCode !== 500) { + throw new ConflictException( + 'MIGRATION_DUPLICATE_TRANSACTION', + `Document ${dto.payload.documentNumber} already imported in batch ${dto.payload.batchId}`, + 'เอกสารนี้ได้รับการนำเข้าในระบบ Staging/Production แล้ว' + ); + } + const activeJob = await this.aiBatchQueue.getJob(idempotencyKey); + if (activeJob) { + return { success: true, jobId: String(activeJob.id) }; + } + const defaultProject = await this.importTransactionRepo.manager.findOne( + Project, + { where: {} } + ); + const projectPublicId = + defaultProject?.publicId ?? '00000000-0000-0000-0000-000000000000'; + try { + const job = await this.aiBatchQueue.add( + 'migrate-document', + { + jobType: 'migrate-document', + documentPublicId: dto.payload.tempAttachmentId, + projectPublicId, + payload: { + documentNumber: dto.payload.documentNumber, + title: dto.payload.title, + batchId: dto.payload.batchId, + existingTags: dto.payload.existingTags, + systemCategories: dto.payload.systemCategories, + }, + idempotencyKey, + }, + { jobId: idempotencyKey } + ); + return { success: true, jobId: String(job.id) }; + } catch (err: unknown) { + const error = err instanceof Error ? err : new Error(String(err)); + this.logger.error('AI job queue failed', { + documentPublicId: dto.payload.tempAttachmentId, + error, + }); + return { success: false, error }; + } + } + /** อ่านสถานะ job จาก ai-realtime หรือ ai-batch เพื่อให้ frontend polling ได้ */ async getAiJobStatus(jobId: string): Promise { const realtimeJob = await this.aiRealtimeQueue?.getJob(jobId); diff --git a/backend/src/modules/ai/dto/ai-job-result.dto.ts b/backend/src/modules/ai/dto/ai-job-result.dto.ts new file mode 100644 index 00000000..7b09bb52 --- /dev/null +++ b/backend/src/modules/ai/dto/ai-job-result.dto.ts @@ -0,0 +1,89 @@ +// File: src/modules/ai/dto/ai-job-result.dto.ts +// Change Log: +// - 2026-05-22: สร้าง AiJobResultDto สำหรับจัดรูปแบบและตรวจสอบผลลัพธ์ของงาน AI (ADR-028) + +import { ApiProperty } from '@nestjs/swagger'; +import { + IsBoolean, + IsNumber, + IsString, + IsArray, + ValidateNested, + IsOptional, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * แท็กที่ AI แนะนำจากการวิเคราะห์เอกสาร + */ +export class SuggestedTagDto { + @ApiProperty({ description: 'ชื่อแท็กที่แนะนำ' }) + @IsString() + name!: string; + + @ApiProperty({ description: 'คำอธิบายเกี่ยวกับแท็ก' }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ description: 'ระบุว่าเป็นแท็กใหม่ในระบบหรือไม่' }) + @IsBoolean() + isNew!: boolean; + + @ApiProperty({ description: 'ระดับความมั่นใจของ AI ต่อแท็กนี้ (0.0–1.0)' }) + @IsNumber() + confidence!: number; +} + +/** + * ผลลัพธ์จากการวิเคราะห์เอกสารของ AI สำหรับการย้ายระบบ + */ +export class AiJobResultDto { + @ApiProperty({ description: 'เอกสารมีความถูกต้องและสมบูรณ์หรือไม่' }) + @IsBoolean() + isValid!: boolean; + + @ApiProperty({ + description: 'ระดับความมั่นใจเฉลี่ยโดยรวมของเอกสาร (0.0–1.0)', + }) + @IsNumber() + confidence!: number; + + @ApiProperty({ description: 'หมวดหมู่ของเอกสารโต้ตอบที่แนะนำ' }) + @IsString() + category!: string; + + @ApiProperty({ description: 'บทสรุปโดยย่อของเอกสาร' }) + @IsString() + summary!: string; + + @ApiProperty({ + type: [SuggestedTagDto], + description: 'รายการแท็กที่ AI แนะนำ', + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SuggestedTagDto) + suggestedTags!: SuggestedTagDto[]; + + @ApiProperty({ + type: [String], + description: 'รายการจุดผิดพลาดหรือข้อควรระวังที่พบในเอกสาร', + }) + @IsArray() + @IsString({ each: true }) + detectedIssues!: string[]; + + @ApiProperty({ + enum: ['fast-path', 'slow-path'], + description: 'วิธีการสกัดข้อความจากเอกสาร', + }) + @IsString() + ocrMethod!: 'fast-path' | 'slow-path'; + + @ApiProperty({ + description: 'ระยะเวลาที่ใช้ในการสกัดข้อมูลและวิเคราะห์ (ms)', + }) + @IsNumber() + processingTimeMs!: number; +} diff --git a/backend/src/modules/ai/dto/submit-ai-job.dto.ts b/backend/src/modules/ai/dto/submit-ai-job.dto.ts new file mode 100644 index 00000000..63a913f5 --- /dev/null +++ b/backend/src/modules/ai/dto/submit-ai-job.dto.ts @@ -0,0 +1,96 @@ +// File: src/modules/ai/dto/submit-ai-job.dto.ts +// Change Log: +// - 2026-05-22: สร้าง SubmitAiJobDto สำหรับรับงานประมวลผลการย้ายเอกสารของ AI (ADR-028) + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsUUID, + IsString, + IsNotEmpty, + IsOptional, + IsArray, + ValidateNested, + IsObject, + IsIn, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * ตัวเลือกแท็กประกอบการวิเคราะห์ของ AI + */ +export class TagOptionDto { + @ApiPropertyOptional({ description: 'UUID ของแท็กที่มีอยู่แล้วในโครงการ' }) + @IsUUID() + @IsOptional() + publicId?: string; + + @ApiProperty({ description: 'ชื่อแท็ก' }) + @IsString() + @IsNotEmpty() + tagName!: string; + + @ApiPropertyOptional({ description: 'รหัสสีของแท็ก' }) + @IsString() + @IsOptional() + colorCode?: string; +} + +/** + * Payload ข้อมูลเอกสารเก่าสำหรับการทำ Migration + */ +export class MigrateDocumentPayloadDto { + @ApiProperty({ description: 'UUID ของ temp attachment ในระบบ' }) + @IsUUID() + tempAttachmentId!: string; + + @ApiProperty({ description: 'เลขที่เอกสารเก่า' }) + @IsString() + @IsNotEmpty() + documentNumber!: string; + + @ApiProperty({ description: 'ชื่อเรื่องเอกสาร' }) + @IsString() + @IsNotEmpty() + title!: string; + + @ApiProperty({ + type: [TagOptionDto], + description: 'รายการแท็กโครงการที่มีอยู่ก่อนแล้ว', + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => TagOptionDto) + existingTags?: TagOptionDto[]; + + @ApiProperty({ type: [String], description: 'หมวดหมู่เอกสารหลักที่มีในระบบ' }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + systemCategories?: string[]; + + @ApiProperty({ description: 'รหัสกลุ่มการนำเข้า (Batch ID)' }) + @IsString() + @IsNotEmpty() + batchId!: string; +} + +/** + * DTO สำหรับส่งคำขอเปิดงานประมวลผล AI (AI processing job submission) + */ +export class SubmitAiJobDto { + @ApiProperty({ + example: 'migrate-document', + description: 'ชนิดงานประมวลผล AI', + }) + @IsString() + @IsNotEmpty() + @IsIn(['migrate-document']) + type!: string; + + @ApiProperty({ type: MigrateDocumentPayloadDto }) + @IsObject() + @ValidateNested() + @Type(() => MigrateDocumentPayloadDto) + payload!: MigrateDocumentPayloadDto; +} diff --git a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts index 33c9b08e..0877c32f 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.spec.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.spec.ts @@ -3,6 +3,7 @@ // - 2026-05-21: สร้าง Unit Test สำหรับ AiBatchProcessor ครอบคลุม embed-document และ sandbox-rag (T032). // - 2026-05-21: เพิ่มการทดสอบ sandbox-extract พร้อม mock OcrService, OllamaService และ Redis (T039). // - 2026-05-21: แก้ไข ESLint unexpected any และ unsafe member access โดยกำหนด type ให้ redis เป็น Record +// - 2026-05-22: เพิ่ม Mock dependencies (ProjectRepository, AiAuditLogRepository, TagsService, MigrationService) เพื่อแก้ปัญหา Nest resolve dependency ใน unit test และปรับโครงสร้างฟังก์ชันไม่มีบรรทัดว่าง (Zero Blank Lines) ตามกฎเหล็ก import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -14,6 +15,10 @@ import { AiRagService } from '../ai-rag.service'; import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; import { OcrService } from '../services/ocr.service'; import { OllamaService } from '../services/ollama.service'; +import { Project } from '../../project/entities/project.entity'; +import { AiAuditLog } from '../entities/ai-audit-log.entity'; +import { TagsService } from '../../tags/tags.service'; +import { MigrationService } from '../../migration/migration.service'; describe('AiBatchProcessor', () => { let processor: AiBatchProcessor; @@ -38,13 +43,17 @@ describe('AiBatchProcessor', () => { .mockResolvedValue({ text: 'OCR text LCBP3-CIV-001 Civil' }), }; const mockOllamaService = { + getMainModelName: jest.fn().mockReturnValue('gemma4:e4b'), generate: jest.fn().mockResolvedValue( JSON.stringify({ documentNumber: 'LCBP3-CIV-001', subject: 'Foundation Inspection Report', discipline: 'Civil', + category: 'Correspondence', date: '2026-05-20', confidence: 0.95, + tags: ['foundation'], + summary: 'summary text', }) ), }; @@ -52,8 +61,35 @@ describe('AiBatchProcessor', () => { setex: jest.fn().mockResolvedValue('OK'), }; const mockAttachmentRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 1, + publicId: 'doc-uuid-123', + filePath: '/files/test.pdf', + uploadedByUserId: 10, + }), update: jest.fn().mockResolvedValue({ affected: 1 }), }; + const mockProjectRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 2, + publicId: 'proj-uuid-456', + }), + }; + const mockAiAuditLogRepo = { + create: jest.fn().mockReturnValue({}), + save: jest.fn().mockResolvedValue({}), + }; + const mockTagsService = { + findOrCreateTags: jest + .fn() + .mockResolvedValue([ + { id: 5, publicId: 'tag-uuid-999', tagName: 'foundation' }, + ]), + }; + const mockMigrationService = { + createError: jest.fn().mockResolvedValue(undefined), + enqueueRecord: jest.fn().mockResolvedValue(undefined), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -67,6 +103,16 @@ describe('AiBatchProcessor', () => { provide: getRepositoryToken(Attachment), useValue: mockAttachmentRepo, }, + { + provide: getRepositoryToken(Project), + useValue: mockProjectRepo, + }, + { + provide: getRepositoryToken(AiAuditLog), + useValue: mockAiAuditLogRepo, + }, + { provide: TagsService, useValue: mockTagsService }, + { provide: MigrationService, useValue: mockMigrationService }, ], }).compile(); processor = module.get(AiBatchProcessor); @@ -148,4 +194,42 @@ describe('AiBatchProcessor', () => { expect.stringContaining('completed') ); }); + it('ควรประมวลผล migrate-document โดยจำลอง OCR, AI และเรียก migrationService.enqueueRecord', async () => { + const job = { + id: 'job-migrate', + data: { + jobType: 'migrate-document', + documentPublicId: 'doc-uuid-123', + projectPublicId: 'proj-uuid-456', + payload: { + documentNumber: 'LEGACY-001', + title: 'Legacy Title', + senderOrgId: 1, + receiverOrgId: 2, + }, + idempotencyKey: 'idem-migrate-123', + batchId: 'batch-999', + }, + } as unknown as Job; + await processor.process(job); + expect(attachmentRepo.findOne).toHaveBeenCalledWith({ + where: { publicId: 'doc-uuid-123' }, + }); + expect(ocrService.detectAndExtract).toHaveBeenCalledWith({ + pdfPath: '/files/test.pdf', + }); + expect(ollamaService.generate).toHaveBeenCalledTimes(1); + expect(mockTagsService.findOrCreateTags).toHaveBeenCalledTimes(1); + expect(mockMigrationService.enqueueRecord).toHaveBeenCalledWith( + expect.objectContaining({ + documentNumber: 'LCBP3-CIV-001', + subject: 'Foundation Inspection Report', + category: 'Correspondence', + isValid: true, + confidence: 0.95, + }) + ); + expect(mockAiAuditLogRepo.create).toHaveBeenCalledTimes(1); + expect(mockAiAuditLogRepo.save).toHaveBeenCalledTimes(1); + }); }); diff --git a/backend/src/modules/ai/processors/ai-batch.processor.ts b/backend/src/modules/ai/processors/ai-batch.processor.ts index cf60dd22..84bae441 100644 --- a/backend/src/modules/ai/processors/ai-batch.processor.ts +++ b/backend/src/modules/ai/processors/ai-batch.processor.ts @@ -5,6 +5,7 @@ // - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox. // - 2026-05-21: พัฒนาระบบประมวลผล sandbox-extract พร้อมเชื่อมต่อ OcrService, OllamaService และ Redis cache // - 2026-05-21: แก้ไข ESLint unused variable สำหรับ parseError ใน catch block +// - 2026-05-22: แก้ไข type compilation error ใน processMigrateDocument และนำช่องว่างภายในฟังก์ชันออก import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Logger } from '@nestjs/common'; @@ -19,13 +20,29 @@ import { EmbeddingService } from '../services/embedding.service'; import { AiRagService } from '../ai-rag.service'; import { OcrService } from '../services/ocr.service'; import { OllamaService } from '../services/ollama.service'; +import { Project } from '../../project/entities/project.entity'; +import { AiAuditLog, AiAuditStatus } from '../entities/ai-audit-log.entity'; +import { TagsService } from '../../tags/tags.service'; +import { MigrationService } from '../../migration/migration.service'; +import { MigrationErrorType } from '../../migration/entities/migration-error.entity'; + +interface MigrateDocumentMetadata extends Record { + documentNumber?: string; + subject?: string; + category?: string; + date?: string; + confidence?: number; + tags?: string[]; + summary?: string; +} export type AiBatchJobType = | 'ocr' | 'extract-metadata' | 'embed-document' | 'sandbox-rag' - | 'sandbox-extract'; + | 'sandbox-extract' + | 'migrate-document'; export interface AiBatchJobData { jobType: AiBatchJobType; @@ -36,6 +53,41 @@ export interface AiBatchJobData { idempotencyKey: string; } +const readString = (value: unknown): string | undefined => + typeof value === 'string' && value.trim().length > 0 ? value : undefined; + +const readNumberId = (value: unknown): number | undefined => + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim().length > 0 + ? Number(value) + : undefined; + +const toStringList = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; + +const parseMigrateDocumentMetadata = ( + cleanedResponse: string +): MigrateDocumentMetadata => { + const parsed: unknown = JSON.parse(cleanedResponse); + if (!parsed || typeof parsed !== 'object') { + return {}; + } + const source = parsed as Record; + return { + documentNumber: readString(source.documentNumber), + subject: readString(source.subject), + category: readString(source.category), + date: readString(source.date), + confidence: + typeof source.confidence === 'number' ? source.confidence : undefined, + tags: toStringList(source.tags), + summary: readString(source.summary), + }; +}; + /** Processor สำหรับงาน AI batch ที่รันทีละงานเพื่อคุม VRAM */ @Processor(QUEUE_AI_BATCH, { concurrency: 1 }) export class AiBatchProcessor extends WorkerHost { @@ -45,10 +97,16 @@ export class AiBatchProcessor extends WorkerHost { constructor( @InjectRepository(Attachment) private readonly attachmentRepo: Repository, + @InjectRepository(Project) + private readonly projectRepo: Repository, + @InjectRepository(AiAuditLog) + private readonly aiAuditLogRepo: Repository, private readonly embeddingService: EmbeddingService, private readonly ragService: AiRagService, private readonly ocrService: OcrService, private readonly ollamaService: OllamaService, + private readonly tagsService: TagsService, + private readonly migrationService: MigrationService, @InjectRedis() private readonly redis: Redis ) { super(); @@ -97,6 +155,15 @@ export class AiBatchProcessor extends WorkerHost { ); await this.processSandboxExtract(job.data); return; + case 'migrate-document': + this.logger.log( + `Migrate document job processing — jobId=${String(job.id)}` + ); + await this.processMigrateDocument(job); + if (!isSandbox) { + await this.setAiProcessingStatus(job.data.documentPublicId, 'DONE'); + } + return; default: { const unreachable: never = job.data.jobType; throw new Error( @@ -248,4 +315,193 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co throw err; } } + + private async processMigrateDocument( + job: Job + ): Promise { + const startTime = Date.now(); + const { documentPublicId, projectPublicId, payload, batchId } = job.data; + const docNumber = payload.documentNumber as string; + const attachment = await this.attachmentRepo.findOne({ + where: { publicId: documentPublicId }, + }); + if (!attachment) { + throw new Error(`ไม่พบ attachment สำหรับ publicId: ${documentPublicId}`); + } + const project = await this.projectRepo.findOne({ + where: { publicId: projectPublicId }, + }); + if (!project) { + throw new Error(`ไม่พบโครงการสำหรับ publicId: ${projectPublicId}`); + } + let ocrResult; + try { + ocrResult = await this.ocrService.detectAndExtract({ + pdfPath: attachment.filePath, + }); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + this.logger.error(`OCR สกัดข้อมูลล้มเหลว: ${errMsg}`); + await this.migrationService.createError({ + batchId: batchId || 'unknown', + documentNumber: docNumber, + errorType: MigrationErrorType.FILE_ERROR, + errorMessage: errMsg, + }); + await this.saveAiAuditLog({ + documentPublicId, + aiModel: 'ocr-engine', + status: AiAuditStatus.FAILED, + errorMessage: errMsg, + processingTimeMs: Date.now() - startTime, + }); + throw err; + } + const prompt = `You are a professional document intelligence engine. +Analyze the following OCR text extracted from a legacy project document and extract the metadata fields. +OCR TEXT: +${ocrResult.text} +Extract these fields: +1. documentNumber: The official document number or code. If not found, return null. +2. subject: The main subject, title, or topic of the document. If not found, return null. +3. discipline: Must be exactly one of: "Civil", "Mechanical", "Electrical", "Architectural", or null if not specified. +4. category: Must be exactly one of: "Correspondence", "Transmittal", "Circulation", "RFA", "Shop Drawing", "Contract Drawing", or null if not specified. +5. date: The issue/document date in YYYY-MM-DD format. If not found, return null. +6. confidence: A float between 0.0 and 1.0 indicating your confidence in this extraction. +7. tags: An array of tags/keywords (strings) that describe the document. +8. summary: A short 1-2 sentence summary of the document contents. +Return ONLY a valid JSON object matching this schema. Do NOT include markdown code blocks, HTML, or any conversational text. Example: +{ + "documentNumber": "LCBP3-CIV-001", + "subject": "Foundation Inspection Report", + "discipline": "Civil", + "category": "Correspondence", + "date": "2026-05-20", + "confidence": 0.95, + "tags": ["foundation", "inspection", "concrete"], + "summary": "This document is a foundation inspection report for the LCBP3 project, confirming concrete strength." +}`; + let aiResponse: string; + try { + aiResponse = await this.ollamaService.generate(prompt); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + this.logger.error(`การวิเคราะห์ของ AI ล้มเหลว: ${errMsg}`); + await this.migrationService.createError({ + batchId: batchId || 'unknown', + documentNumber: docNumber, + errorType: MigrationErrorType.API_ERROR, + errorMessage: errMsg, + }); + await this.saveAiAuditLog({ + documentPublicId, + aiModel: this.ollamaService.getMainModelName(), + status: AiAuditStatus.FAILED, + errorMessage: errMsg, + processingTimeMs: Date.now() - startTime, + }); + throw err; + } + const cleanedResponse = aiResponse + .replace(/```json/g, '') + .replace(/```/g, '') + .trim(); + let extractedMetadata: MigrateDocumentMetadata; + try { + extractedMetadata = parseMigrateDocumentMetadata(cleanedResponse); + } catch (_err: unknown) { + const errMsg = `ไม่สามารถแปลงผลลัพธ์ของ AI เป็น JSON ได้: ${cleanedResponse}`; + this.logger.error(errMsg); + await this.migrationService.createError({ + batchId: batchId || 'unknown', + documentNumber: docNumber, + errorType: MigrationErrorType.AI_PARSE_ERROR, + errorMessage: errMsg, + rawAiResponse: aiResponse, + }); + await this.saveAiAuditLog({ + documentPublicId, + aiModel: this.ollamaService.getMainModelName(), + status: AiAuditStatus.FAILED, + errorMessage: errMsg, + processingTimeMs: Date.now() - startTime, + }); + throw new Error(errMsg); + } + let mappedTags: Record[] = []; + if (extractedMetadata.tags && extractedMetadata.tags.length > 0) { + const tags = await this.tagsService.findOrCreateTags( + project.id, + extractedMetadata.tags, + attachment.uploadedByUserId + ); + mappedTags = tags.map((t) => ({ + publicId: t.publicId, + tagName: t.tagName, + })); + } + const confidence = + typeof extractedMetadata.confidence === 'number' + ? extractedMetadata.confidence + : 0.5; + const isValid = confidence >= 0.6 && !!extractedMetadata.documentNumber; + const payloadTitle = readString(payload.title); + await this.migrationService.enqueueRecord({ + documentNumber: extractedMetadata.documentNumber || docNumber, + subject: extractedMetadata.subject || payloadTitle, + originalSubject: payloadTitle, + body: extractedMetadata.summary || '', + category: extractedMetadata.category || 'Correspondence', + aiSummary: extractedMetadata.summary || '', + projectId: project.id, + senderOrgId: readNumberId(payload.senderOrgId), + receiverOrgId: readNumberId(payload.receiverOrgId), + issuedDate: extractedMetadata.date || undefined, + receivedDate: extractedMetadata.date || undefined, + extractedTags: mappedTags, + tempAttachmentId: attachment.id, + isValid, + confidence, + aiJobId: String(job.id), + }); + await this.saveAiAuditLog({ + documentPublicId, + aiModel: this.ollamaService.getMainModelName(), + status: AiAuditStatus.SUCCESS, + aiSuggestionJson: extractedMetadata, + confidenceScore: confidence, + processingTimeMs: Date.now() - startTime, + }); + this.logger.log( + `ประมวลผลเอกสาร ${docNumber} สำเร็จและถูกส่งเข้า Staging Queue แล้ว` + ); + } + + private async saveAiAuditLog(data: { + documentPublicId: string; + aiModel: string; + status: AiAuditStatus; + aiSuggestionJson?: Record; + confidenceScore?: number; + processingTimeMs?: number; + errorMessage?: string; + }): Promise { + try { + const log = this.aiAuditLogRepo.create({ + documentPublicId: data.documentPublicId, + aiModel: data.aiModel, + modelName: data.aiModel, + status: data.status, + aiSuggestionJson: data.aiSuggestionJson, + confidenceScore: data.confidenceScore, + processingTimeMs: data.processingTimeMs, + errorMessage: data.errorMessage, + }); + await this.aiAuditLogRepo.save(log); + } catch (err: unknown) { + this.logger.error( + `บันทึก ai_audit_logs ล้มเหลว: ${err instanceof Error ? err.message : String(err)}` + ); + } + } } diff --git a/backend/src/modules/ai/workers/cleanup-temp-files.worker.ts b/backend/src/modules/ai/workers/cleanup-temp-files.worker.ts new file mode 100644 index 00000000..65d6e4fc --- /dev/null +++ b/backend/src/modules/ai/workers/cleanup-temp-files.worker.ts @@ -0,0 +1,88 @@ +// File: src/modules/ai/workers/cleanup-temp-files.worker.ts +// Change Log: +// - 2026-05-22: อัปเดตและสร้างตัวล้างไฟล์ชั่วคราว (T016) เพื่อลบไฟล์ที่หมดอายุ 24 ชม. + +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as fs from 'fs-extra'; +import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; +import { + MigrationReviewQueue, + MigrationReviewStatus, +} from '../../migration/entities/migration-review-queue.entity'; + +@Injectable() +export class CleanupTempFilesWorker { + private readonly logger = new Logger(CleanupTempFilesWorker.name); + + constructor( + @InjectRepository(Attachment) + private readonly attachmentRepository: Repository, + @InjectRepository(MigrationReviewQueue) + private readonly reviewQueueRepository: Repository + ) {} + + /** + * รันทุกชั่วโมงเพื่อลบไฟล์แนบชั่วคราวที่ครบ 24 ชั่วโมงและไม่ได้ถูกคอมมิต + * ยกเว้นไฟล์ที่ถูกอ้างอิงโดยรายการที่สถานะเป็น PENDING ใน Migration Review Queue + */ + @Cron(CronExpression.EVERY_HOUR) + async handleCleanup(): Promise { + this.logger.log('Starting temporary files cleanup worker...'); + try { + const oneDayAgo = new Date(); + oneDayAgo.setHours(oneDayAgo.getHours() - 24); + const pendingRecords = await this.reviewQueueRepository.find({ + select: ['tempAttachmentId'], + where: { status: MigrationReviewStatus.PENDING }, + }); + const pendingAttachmentIds = pendingRecords + .map((r) => r.tempAttachmentId) + .filter((id): id is number => id !== undefined && id !== null); + const query = this.attachmentRepository + .createQueryBuilder('attachment') + .where('attachment.isTemporary = :isTemporary', { isTemporary: true }) + .andWhere('attachment.createdAt < :oneDayAgo', { oneDayAgo }); + if (pendingAttachmentIds.length > 0) { + query.andWhere('attachment.id NOT IN (:...pendingAttachmentIds)', { + pendingAttachmentIds, + }); + } + const expiredAttachments = await query.getMany(); + if (expiredAttachments.length === 0) { + this.logger.log('No expired temporary files found.'); + return; + } + this.logger.log( + `Found ${expiredAttachments.length} expired temporary files. Deleting...` + ); + let deletedCount = 0; + let failedCount = 0; + for (const att of expiredAttachments) { + try { + if (await fs.pathExists(att.filePath)) { + await fs.remove(att.filePath); + } + await this.attachmentRepository.remove(att); + deletedCount++; + } catch (error) { + const errMessage = (error as Error).message; + this.logger.error( + `Failed to delete temporary file ID ${att.id}: ${errMessage}` + ); + failedCount++; + } + } + this.logger.log( + `Temporary files cleanup completed. Deleted: ${deletedCount}, Failed: ${failedCount}` + ); + } catch (err) { + const errMsg = (err as Error).message; + this.logger.error( + `Error occurred during temporary files cleanup: ${errMsg}` + ); + } + } +} diff --git a/backend/src/modules/common/constants/bullmq.constants.ts b/backend/src/modules/common/constants/bullmq.constants.ts new file mode 100644 index 00000000..f8c032ff --- /dev/null +++ b/backend/src/modules/common/constants/bullmq.constants.ts @@ -0,0 +1,6 @@ +// File: src/modules/common/constants/bullmq.constants.ts +// Change Log: +// - 2026-05-22: สร้างไฟล์เพื่อกำหนดคีย์และประเภทของ BullMQ Jobs (ADR-028) + +/** BullMQ Job Type สำหรับการรันงานประเภทต่างๆ */ +export const JOB_MIGRATE_DOCUMENT = 'migrate-document'; diff --git a/backend/src/modules/migration/dto/commit-migration-review.dto.ts b/backend/src/modules/migration/dto/commit-migration-review.dto.ts new file mode 100644 index 00000000..bdc6c232 --- /dev/null +++ b/backend/src/modules/migration/dto/commit-migration-review.dto.ts @@ -0,0 +1,72 @@ +// File: src/modules/migration/dto/commit-migration-review.dto.ts +// Change Log: +// - 2026-05-22: Initial creation for ADR-028 Migration Review Commit (US2) +// - 2026-05-22: Update to support hybrid ID (number | string) for projects and organizations per ADR-019 + +import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CommitMigrationReviewDto { + @ApiProperty({ + description: 'UUID ของรายการใน Staging Migration Review Queue', + }) + @IsString() + @IsNotEmpty() + publicId!: string; + + @ApiProperty({ description: 'ชื่อเรื่อง (แก้ไขได้)', required: false }) + @IsString() + @IsOptional() + subject?: string; + + @ApiProperty({ description: 'หมวดหมู่เอกสาร (แก้ไขได้)', required: false }) + @IsString() + @IsOptional() + category?: string; + + @ApiProperty({ + description: 'ID หรือ UUID ของ Project (แก้ไขได้)', + required: false, + }) + @IsOptional() + projectId?: number | string; + + @ApiProperty({ + description: 'ID หรือ UUID ขององค์กรผู้ส่ง (แก้ไขได้)', + required: false, + }) + @IsOptional() + senderId?: number | string; + + @ApiProperty({ + description: 'ID หรือ UUID ขององค์กรผู้รับ (แก้ไขได้)', + required: false, + }) + @IsOptional() + receiverId?: number | string; + + @ApiProperty({ description: 'วันที่ออกเอกสาร (แก้ไขได้)', required: false }) + @IsString() + @IsOptional() + issuedDate?: string; + + @ApiProperty({ description: 'วันที่รับเอกสาร (แก้ไขได้)', required: false }) + @IsString() + @IsOptional() + receivedDate?: string; + + @ApiProperty({ + description: 'รายการแท็กภาษาไทย (แก้ไขได้)', + required: false, + type: [String], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + tags?: string[]; + + @ApiProperty({ description: 'เนื้อหาจดหมาย (แก้ไขได้)', required: false }) + @IsString() + @IsOptional() + body?: string; +} diff --git a/backend/src/modules/migration/dto/enqueue-migration.dto.ts b/backend/src/modules/migration/dto/enqueue-migration.dto.ts index 5e901e90..ac42814b 100644 --- a/backend/src/modules/migration/dto/enqueue-migration.dto.ts +++ b/backend/src/modules/migration/dto/enqueue-migration.dto.ts @@ -78,4 +78,8 @@ export class EnqueueMigrationDto { @IsArray() @IsOptional() aiIssues?: Record[]; + + @IsString() + @IsOptional() + aiJobId?: string; } diff --git a/backend/src/modules/migration/entities/migration-review-queue.entity.ts b/backend/src/modules/migration/entities/migration-review-queue.entity.ts index 4a9176ce..1fea898c 100644 --- a/backend/src/modules/migration/entities/migration-review-queue.entity.ts +++ b/backend/src/modules/migration/entities/migration-review-queue.entity.ts @@ -1,3 +1,7 @@ +// File: src/modules/migration/entities/migration-review-queue.entity.ts +// Change Log: +// - 2026-05-22: เพิ่มฟิลด์ aiJobId สำหรับเก็บ jobId ของ BullMQ (ADR-028) + import { Entity, Column, @@ -5,14 +9,17 @@ import { CreateDateColumn, } from 'typeorm'; +import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity'; + export enum MigrationReviewStatus { PENDING = 'PENDING', APPROVED = 'APPROVED', + IMPORTED = 'IMPORTED', REJECTED = 'REJECTED', } @Entity('migration_review_queue') -export class MigrationReviewQueue { +export class MigrationReviewQueue extends UuidBaseEntity { @PrimaryGeneratedColumn() id!: number; @@ -86,6 +93,9 @@ export class MigrationReviewQueue { @Column({ name: 'temp_attachment_id', type: 'int', nullable: true }) tempAttachmentId?: number; + @Column({ name: 'ai_job_id', type: 'varchar', length: 36, nullable: true }) + aiJobId?: string | null; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; } diff --git a/backend/src/modules/migration/migration-review.controller.ts b/backend/src/modules/migration/migration-review.controller.ts new file mode 100644 index 00000000..83d498da --- /dev/null +++ b/backend/src/modules/migration/migration-review.controller.ts @@ -0,0 +1,50 @@ +// File: src/modules/migration/migration-review.controller.ts +// Change Log: +// - 2026-05-22: Initial creation for US2 - Staging Migration Review Commit (T020b) + +import { Controller, Post, Body, Headers, UseGuards } from '@nestjs/common'; +import { MigrationReviewService } from './migration-review.service'; +import { CommitMigrationReviewDto } from './dto/commit-migration-review.dto'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../../common/auth/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { User } from '../user/entities/user.entity'; +import { ValidationException } from '../../common/exceptions'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiHeader, +} from '@nestjs/swagger'; + +@ApiTags('Migration Review') +@ApiBearerAuth() +@Controller('ai/migration') +export class MigrationReviewController { + constructor(private readonly reviewService: MigrationReviewService) {} + + @Post('review') + @UseGuards(JwtAuthGuard, PermissionsGuard) + @RequirePermission('migration.commit') + @ApiOperation({ + summary: + 'Approve and commit a document from staging review queue into the live system', + }) + @ApiHeader({ + name: 'Idempotency-Key', + description: 'Unique key per commit request to prevent duplicate inserts', + required: true, + }) + async commitRecord( + @Body() dto: CommitMigrationReviewDto, + @Headers('idempotency-key') idempotencyKey: string, + @CurrentUser() user: User + ) { + if (!idempotencyKey) { + throw new ValidationException('Idempotency-Key header is required'); + } + const userId = user?.user_id || 5; + return this.reviewService.commitRecord(dto, userId); + } +} diff --git a/backend/src/modules/migration/migration-review.service.spec.ts b/backend/src/modules/migration/migration-review.service.spec.ts new file mode 100644 index 00000000..d397f6de --- /dev/null +++ b/backend/src/modules/migration/migration-review.service.spec.ts @@ -0,0 +1,113 @@ +// File: src/modules/migration/migration-review.service.spec.ts +// Change Log: +// - 2026-05-22: Initial creation of unit test suite for MigrationReviewService (T020a) +// - 2026-05-22: เพิ่ม FR-007a test cases สำหรับ pessimistic lock + race condition (SELECT FOR UPDATE) +import { Test, TestingModule } from '@nestjs/testing'; +import { MigrationReviewService } from './migration-review.service'; +import { UuidResolverService } from '../../common/services/uuid-resolver.service'; +import { DataSource } from 'typeorm'; +import { MigrationReviewStatus } from './entities/migration-review-queue.entity'; +describe('MigrationReviewService', () => { + let service: MigrationReviewService; + const mockUuidResolverService = { + resolveProjectId: jest.fn().mockResolvedValue(1), + resolveOrganizationId: jest.fn().mockResolvedValue(2), + }; + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + count: jest.fn(), + update: jest.fn(), + query: jest.fn(), + }, + }; + const mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + }; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MigrationReviewService, + { + provide: DataSource, + useValue: mockDataSource, + }, + { + provide: UuidResolverService, + useValue: mockUuidResolverService, + }, + ], + }).compile(); + service = module.get(MigrationReviewService); + jest.clearAllMocks(); + }); + it('should be defined', () => { + expect(service).toBeDefined(); + }); + describe('commitRecord — FR-007a: pessimistic lock (SELECT FOR UPDATE)', () => { + const dto = { publicId: 'test-uuid-001', projectId: 1 }; + const userId = 99; + it('FR-007a: ควร throw SystemException เมื่อไม่พบ record (NotFoundException wrapped)', async () => { + mockQueryRunner.manager.findOne.mockResolvedValueOnce(null); + await expect( + service.commitRecord(dto as never, userId) + ).rejects.toThrow(); + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + it('FR-007a: ควร throw SystemException เมื่อ record มี status = IMPORTED (ไม่ใช่ PENDING)', async () => { + mockQueryRunner.manager.findOne.mockResolvedValueOnce({ + id: 1, + publicId: 'test-uuid-001', + status: MigrationReviewStatus.IMPORTED, + documentNumber: 'DOC-001', + }); + await expect( + service.commitRecord(dto as never, userId) + ).rejects.toThrow(); + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + it('FR-007a: ควร throw SystemException เมื่อ record มี status = REJECTED', async () => { + mockQueryRunner.manager.findOne.mockResolvedValueOnce({ + id: 1, + publicId: 'test-uuid-001', + status: MigrationReviewStatus.REJECTED, + documentNumber: 'DOC-001', + }); + await expect( + service.commitRecord(dto as never, userId) + ).rejects.toThrow(); + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.release).toHaveBeenCalled(); + }); + it('FR-007a: ควรเรียก findOne พร้อม lock pessimistic_write เสมอ', async () => { + mockQueryRunner.manager.findOne.mockResolvedValueOnce(null); + await expect( + service.commitRecord(dto as never, userId) + ).rejects.toThrow(); + expect(mockQueryRunner.manager.findOne).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + where: { publicId: dto.publicId }, + lock: { mode: 'pessimistic_write' }, + }) + ); + }); + it('FR-007a: ควร rollback และ release queryRunner เสมอ ไม่ว่าจะ success หรือ error', async () => { + mockQueryRunner.manager.findOne.mockResolvedValueOnce(null); + await expect( + service.commitRecord(dto as never, userId) + ).rejects.toThrow(); + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalledTimes(1); + expect(mockQueryRunner.release).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/src/modules/migration/migration-review.service.ts b/backend/src/modules/migration/migration-review.service.ts new file mode 100644 index 00000000..514cda04 --- /dev/null +++ b/backend/src/modules/migration/migration-review.service.ts @@ -0,0 +1,326 @@ +// File: src/modules/migration/migration-review.service.ts +// Change Log: +// - 2026-05-22: Initial creation for US2 - Migration Review Queue Commit (T020a) +// - 2026-05-22: Integrated UuidResolverService to resolve hybrid identifiers (T020a) + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { + MigrationReviewQueue, + MigrationReviewStatus, +} from './entities/migration-review-queue.entity'; +import { ImportTransaction } from './entities/import-transaction.entity'; +import { Correspondence } from '../correspondence/entities/correspondence.entity'; +import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; +import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; +import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; +import { Project } from '../project/entities/project.entity'; +import { Attachment } from '../../common/file-storage/entities/attachment.entity'; +import { Rfa } from '../rfa/entities/rfa.entity'; +import { RfaRevision } from '../rfa/entities/rfa-revision.entity'; +import { CommitMigrationReviewDto } from './dto/commit-migration-review.dto'; +import { UuidResolverService } from '../../common/services/uuid-resolver.service'; +import { + ConflictException, + NotFoundException, + SystemException, + ValidationException, +} from '../../common/exceptions'; + +const readTagName = (value: Record): string => { + return value.name || value.tagName || ''; +}; + +@Injectable() +export class MigrationReviewService { + private readonly logger = new Logger(MigrationReviewService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly uuidResolverService: UuidResolverService + ) {} + + /** + * ทำการ Commit ข้อมูลเอกสารจาก Staging Review Queue เข้าระบบจริงอย่างเป็นระบบ + * มีการทำ SELECT FOR UPDATE เพื่อป้องกันการกดเบิ้ลหรือการทำงานพร้อมกัน + */ + async commitRecord(dto: CommitMigrationReviewDto, userId: number) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const queueItem = await queryRunner.manager.findOne( + MigrationReviewQueue, + { + where: { publicId: dto.publicId }, + lock: { mode: 'pessimistic_write' }, + } + ); + if (!queueItem) { + throw new NotFoundException( + 'Migration review record not found', + dto.publicId + ); + } + if (queueItem.status !== MigrationReviewStatus.PENDING) { + throw new ConflictException( + 'MIGRATION_ALREADY_PROCESSING', + `Staging record is already processed with status: ${queueItem.status}`, + 'รายการนี้ได้รับการประมวลผลไปแล้ว', + ['กรุณาตรวจสอบหน้า Review Queue อีกครั้งเพื่อความถูกต้อง'] + ); + } + const rawProjectId = dto.projectId ?? queueItem.projectId; + if (!rawProjectId) { + throw new ValidationException('Project ID is required'); + } + const resolvedProjectId = + await this.uuidResolverService.resolveProjectId(rawProjectId); + const project = await queryRunner.manager.findOne(Project, { + where: { id: resolvedProjectId }, + }); + if (!project) { + throw new NotFoundException('Project', String(resolvedProjectId)); + } + const category = dto.category ?? queueItem.aiSuggestedCategory; + if (!category) { + throw new ValidationException('Category is required'); + } + const CATEGORY_ALIAS: Record = { + Correspondence: 'LETTER', + Letter: 'LETTER', + Drawing: 'OTHER', + Report: 'OTHER', + Other: 'OTHER', + }; + const type = await queryRunner.manager.findOne(CorrespondenceType, { + where: { typeName: category }, + }); + let typeId = type + ? type.id + : ( + await queryRunner.manager.findOne(CorrespondenceType, { + where: { typeCode: category }, + }) + )?.id; + if (!typeId && CATEGORY_ALIAS[category]) { + typeId = ( + await queryRunner.manager.findOne(CorrespondenceType, { + where: { typeCode: CATEGORY_ALIAS[category] }, + }) + )?.id; + } + if (!typeId) { + throw new ValidationException( + `Category "${category}" not found in system` + ); + } + let status = await queryRunner.manager.findOne(CorrespondenceStatus, { + where: { statusCode: 'CLBOWN' }, + }); + if (!status) { + status = await queryRunner.manager.findOne(CorrespondenceStatus, { + where: { statusCode: 'DRAFT' }, + }); + } + if (!status) { + throw new SystemException( + 'No default correspondence status found (missing CLBOWN/DRAFT)' + ); + } + const docNum = queueItem.documentNumber; + let correspondence = await queryRunner.manager.findOne(Correspondence, { + where: { + correspondenceNumber: docNum, + projectId: project.id, + }, + }); + const rawSenderId = dto.senderId ?? queueItem.senderOrganizationId; + const resolvedSenderId = rawSenderId + ? await this.uuidResolverService.resolveOrganizationId(rawSenderId) + : undefined; + const rawReceiverId = dto.receiverId ?? queueItem.receiverOrganizationId; + const resolvedReceiverId = rawReceiverId + ? await this.uuidResolverService.resolveOrganizationId(rawReceiverId) + : undefined; + if (!correspondence) { + correspondence = queryRunner.manager.create(Correspondence, { + correspondenceNumber: docNum, + correspondenceTypeId: typeId, + projectId: project.id, + originatorId: resolvedSenderId || undefined, + isInternal: false, + createdBy: userId, + }); + await queryRunner.manager.save(correspondence); + const isRFA = type?.typeCode === 'RFA' || category === 'RFA'; + if (isRFA) { + const rfaTypeRes = await queryRunner.manager.query<{ id: number }[]>( + "SELECT id FROM rfa_types WHERE type_code = 'GEN' LIMIT 1" + ); + const rfa = queryRunner.manager.create(Rfa, { + id: correspondence.id, + rfaTypeId: rfaTypeRes[0]?.id || 1, + createdBy: userId, + }); + await queryRunner.manager.save(Rfa, rfa); + } + } else { + let hasChanges = false; + if (resolvedSenderId && !correspondence.originatorId) { + correspondence.originatorId = resolvedSenderId; + hasChanges = true; + } + if (hasChanges) { + await queryRunner.manager.save(correspondence); + } + } + if (resolvedReceiverId) { + await queryRunner.manager.query( + 'INSERT IGNORE INTO correspondence_recipients (correspondence_id, recipient_organization_id, recipient_type) VALUES (?, ?, ?)', + [correspondence.id, resolvedReceiverId, 'TO'] + ); + } + let attachmentId: number | null = null; + if (queueItem.tempAttachmentId) { + attachmentId = queueItem.tempAttachmentId; + await queryRunner.manager.update( + Attachment, + { id: attachmentId }, + { isTemporary: false } + ); + } + const parseDateStr = (d?: string | Date) => { + if (!d) return undefined; + if (d instanceof Date) return d; + const parsed = new Date(d); + return isNaN(parsed.getTime()) ? undefined : parsed; + }; + const finalSubject = + dto.subject ?? + queueItem.subject ?? + queueItem.originalSubject ?? + 'No Subject'; + const finalBody = dto.body ?? queueItem.body ?? ''; + const issuedDateStr = + dto.issuedDate ?? + (queueItem.issuedDate ? queueItem.issuedDate.toISOString() : undefined); + const receivedDateStr = + dto.receivedDate ?? + (queueItem.receivedDate + ? queueItem.receivedDate.toISOString() + : undefined); + const revisionCount = await queryRunner.manager.count( + CorrespondenceRevision, + { + where: { correspondenceId: correspondence.id }, + } + ); + const revNum = revisionCount; + const revision = queryRunner.manager.create(CorrespondenceRevision, { + correspondenceId: correspondence.id, + revisionNumber: revNum, + revisionLabel: revNum === 0 ? '0' : revNum.toString(), + isCurrent: true, + statusId: status.id, + subject: finalSubject, + description: 'Migrated from legacy system via Human Reviewed Commit', + body: finalBody || undefined, + documentDate: parseDateStr(issuedDateStr), + issuedDate: parseDateStr(issuedDateStr), + receivedDate: parseDateStr(receivedDateStr), + details: { + ai_confidence: queueItem.aiConfidence, + ai_issues: queueItem.aiIssues, + attachment_id: attachmentId, + }, + schemaVersion: 1, + createdBy: userId, + }); + if (revisionCount > 0) { + await queryRunner.manager.update( + CorrespondenceRevision, + { correspondenceId: correspondence.id, isCurrent: true }, + { isCurrent: false } + ); + } + await queryRunner.manager.save(revision); + const isRFA = type?.typeCode === 'RFA' || category === 'RFA'; + if (isRFA) { + const rfaStatusRes = await queryRunner.manager.query<{ id: number }[]>( + "SELECT id FROM rfa_status_codes WHERE status_code = 'APP' LIMIT 1" + ); + const rfaRev = queryRunner.manager.create(RfaRevision, { + id: revision.id, + rfaStatusCodeId: rfaStatusRes[0]?.id || 3, + details: { drawingCount: 0 }, + schemaVersion: 1, + }); + await queryRunner.manager.save(RfaRevision, rfaRev); + } + let tagsToLink: string[] = []; + if (dto.tags && dto.tags.length > 0) { + tagsToLink = dto.tags; + } else if ( + queueItem.extractedTags && + Array.isArray(queueItem.extractedTags) + ) { + tagsToLink = queueItem.extractedTags + .map((tag) => readTagName(tag)) + .filter(Boolean); + } + for (const rawTagName of tagsToLink) { + const tagName = rawTagName.trim().toLowerCase(); + if (!tagName) continue; + const tagRes = await queryRunner.manager.query<{ id: number }[]>( + 'SELECT id FROM tags WHERE project_id = ? AND tag_name = ? LIMIT 1', + [project.id, tagName] + ); + let tagId: number; + if (tagRes && tagRes.length > 0) { + tagId = tagRes[0].id; + } else { + const insertRes = await queryRunner.manager.query<{ + insertId: number; + }>( + "INSERT INTO tags (project_id, tag_name, color_code, created_by) VALUES (?, ?, 'default', ?)", + [project.id, tagName, userId] + ); + tagId = insertRes.insertId; + } + await queryRunner.manager.query( + 'INSERT IGNORE INTO correspondence_tags (correspondence_id, tag_id) VALUES (?, ?)', + [correspondence.id, tagId] + ); + } + const idempotencyKey = `migration_review_${queueItem.id}`; + const transaction = queryRunner.manager.create(ImportTransaction, { + idempotencyKey, + documentNumber: docNum, + batchId: 'HUMAN_REVIEW', + statusCode: 201, + }); + await queryRunner.manager.save(transaction); + queueItem.status = MigrationReviewStatus.IMPORTED; + queueItem.reviewedBy = userId.toString(); + queueItem.reviewedAt = new Date(); + await queryRunner.manager.save(queueItem); + await queryRunner.commitTransaction(); + return { + success: true, + message: 'Staging record successfully imported', + correspondencePublicId: correspondence.publicId, + publicId: queueItem.publicId, + status: queueItem.status, + }; + } catch (error: unknown) { + await queryRunner.rollbackTransaction(); + const errMsg = error instanceof Error ? error.message : String(error); + throw new SystemException( + 'Failed to commit review queue record: ' + errMsg + ); + } finally { + await queryRunner.release(); + } + } +} diff --git a/backend/src/modules/migration/migration.module.ts b/backend/src/modules/migration/migration.module.ts index a60e45ca..634abbdd 100644 --- a/backend/src/modules/migration/migration.module.ts +++ b/backend/src/modules/migration/migration.module.ts @@ -1,7 +1,13 @@ +// File: src/modules/migration/migration.module.ts +// Change Log: +// - 2026-05-22: นำเข้าและลงทะเบียน ExpirePendingReviewsWorker (T016b), Attachment, User, และ NotificationModule เพื่อรองรับระบบยกเลิกรีวิวที่หมดอายุ + import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MigrationController } from './migration.controller'; import { MigrationService } from './migration.service'; +import { MigrationReviewController } from './migration-review.controller'; +import { MigrationReviewService } from './migration-review.service'; import { ImportTransaction } from './entities/import-transaction.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; @@ -9,9 +15,13 @@ import { CorrespondenceType } from '../correspondence/entities/correspondence-ty import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { Project } from '../project/entities/project.entity'; import { FileStorageModule } from '../../common/file-storage/file-storage.module'; +import { Attachment } from '../../common/file-storage/entities/attachment.entity'; +import { User } from '../user/entities/user.entity'; +import { NotificationModule } from '../notification/notification.module'; import { MigrationReviewQueue } from './entities/migration-review-queue.entity'; import { MigrationError } from './entities/migration-error.entity'; +import { ExpirePendingReviewsWorker } from './workers/expire-pending-reviews.worker'; @Module({ imports: [ @@ -24,11 +34,18 @@ import { MigrationError } from './entities/migration-error.entity'; CorrespondenceType, CorrespondenceStatus, Project, + Attachment, + User, ]), FileStorageModule, + NotificationModule, ], - controllers: [MigrationController], - providers: [MigrationService], - exports: [MigrationService], + controllers: [MigrationController, MigrationReviewController], + providers: [ + MigrationService, + MigrationReviewService, + ExpirePendingReviewsWorker, + ], + exports: [MigrationService, MigrationReviewService], }) export class MigrationModule {} diff --git a/backend/src/modules/migration/migration.service.ts b/backend/src/modules/migration/migration.service.ts index 2aa1b4a7..058c9e88 100644 --- a/backend/src/modules/migration/migration.service.ts +++ b/backend/src/modules/migration/migration.service.ts @@ -443,6 +443,7 @@ export class MigrationService { queueItem.extractedTags = dto.extractedTags; queueItem.tempAttachmentId = dto.tempAttachmentId; queueItem.status = autoStatus; + queueItem.aiJobId = dto.aiJobId; if (dto.issuedDate) { const parsed = new Date(dto.issuedDate); diff --git a/backend/src/modules/migration/workers/expire-pending-reviews.worker.ts b/backend/src/modules/migration/workers/expire-pending-reviews.worker.ts new file mode 100644 index 00000000..dc9b54b7 --- /dev/null +++ b/backend/src/modules/migration/workers/expire-pending-reviews.worker.ts @@ -0,0 +1,121 @@ +// File: src/modules/migration/workers/expire-pending-reviews.worker.ts +// Change Log: +// - 2026-05-22: สร้างตัวยกเลิกรายการรีวิวที่ค้างเกิน 30 วัน (T016b) และแจ้งเตือน Admin + +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import * as fs from 'fs-extra'; +import { + MigrationReviewQueue, + MigrationReviewStatus, +} from '../entities/migration-review-queue.entity'; +import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; +import { User } from '../../user/entities/user.entity'; +import { NotificationService } from '../../notification/notification.service'; + +@Injectable() +export class ExpirePendingReviewsWorker { + private readonly logger = new Logger(ExpirePendingReviewsWorker.name); + + constructor( + @InjectRepository(MigrationReviewQueue) + private readonly reviewQueueRepository: Repository, + @InjectRepository(Attachment) + private readonly attachmentRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly notificationService: NotificationService + ) {} + + /** + * รันทุกวันเวลาเที่ยงคืนเพื่อตรวจสอบและยกเลิกรายการรีวิวที่ค้างอยู่ในสถานะ PENDING เกิน 30 วัน + */ + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async handleExpiration(): Promise { + this.logger.log('Starting migration review queue expiration worker...'); + try { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const expiredRecords = await this.reviewQueueRepository.find({ + where: { + status: MigrationReviewStatus.PENDING, + createdAt: LessThan(thirtyDaysAgo), + }, + }); + if (expiredRecords.length === 0) { + this.logger.log('No expired pending reviews found.'); + return; + } + this.logger.log( + `Found ${expiredRecords.length} expired pending reviews. Processing expiration...` + ); + let expiredCount = 0; + for (const record of expiredRecords) { + try { + if (record.tempAttachmentId) { + const att = await this.attachmentRepository.findOne({ + where: { id: record.tempAttachmentId }, + }); + if (att) { + if (await fs.pathExists(att.filePath)) { + await fs.remove(att.filePath); + } + await this.attachmentRepository.remove(att); + } + record.tempAttachmentId = undefined; + } + record.status = MigrationReviewStatus.REJECTED; + record.remarks = 'EXPIRED'; + record.reviewedAt = new Date(); + record.reviewedBy = 'SYSTEM_AUTO_EXPIRATION'; + await this.reviewQueueRepository.save(record); + expiredCount++; + } catch (error) { + const errMessage = (error as Error).message; + this.logger.error( + `Failed to expire pending review record ID ${record.id}: ${errMessage}` + ); + } + } + this.logger.log( + `Auto-expiration complete. Expired ${expiredCount} records.` + ); + if (expiredCount > 0) { + const admins = await this.userRepository + .createQueryBuilder('user') + .innerJoin('user.assignments', 'assignment') + .innerJoin('assignment.role', 'role') + .where('role.roleName IN (:...roles)', { + roles: ['ADMIN', 'SUPERADMIN'], + }) + .andWhere('user.isActive = :isActive', { isActive: true }) + .getMany(); + this.logger.log( + `Notifying ${admins.length} administrators about expired migration reviews.` + ); + for (const admin of admins) { + try { + await this.notificationService.send({ + userId: admin.user_id, + title: 'แจ้งเตือน: รายการนำเข้าเอกสารหมดอายุอัตโนมัติ', + message: `มีรายการนำเข้าเอกสารจำนวน ${expiredCount} รายการที่ค้างรีวิวเกิน 30 วัน ถูกยกเลิกและลบไฟล์ชั่วคราวแล้วโดยระบบอัตโนมัติ`, + type: 'SYSTEM', + }); + } catch (notifErr) { + const notifErrMsg = (notifErr as Error).message; + this.logger.error( + `Failed to send expiration notification to admin ID ${admin.user_id}: ${notifErrMsg}` + ); + } + } + } + } catch (err) { + const errMsg = (err as Error).message; + this.logger.error( + `Error occurred during pending reviews expiration: ${errMsg}` + ); + } + } +} diff --git a/backend/src/modules/tags/dto/create-tag.dto.ts b/backend/src/modules/tags/dto/create-tag.dto.ts new file mode 100644 index 00000000..c80f1411 --- /dev/null +++ b/backend/src/modules/tags/dto/create-tag.dto.ts @@ -0,0 +1,48 @@ +// File: src/modules/tags/dto/create-tag.dto.ts +// Change Log: +// - 2026-05-22: เริ่มต้นสร้าง CreateTagDto สำหรับรับข้อมูลการสร้างแท็กตาม ADR-028 + +import { IsString, IsNotEmpty, IsOptional, Length } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * DTO สำหรับการร้องขอสร้างแท็กใหม่ + */ +export class CreateTagDto { + @ApiPropertyOptional({ + description: 'UUID ของโครงการ (หากไม่มีจะเป็น Global Tag)', + example: '019505a1-7c3e-7000-8000-abc123def456', + }) + @IsOptional() + @IsString() + projectId?: string; + + @ApiProperty({ + description: + 'ชื่อแท็ก (จะถูกจัดเก็บเป็นตัวพิมพ์เล็กและตัดช่องว่างส่วนเกิน)', + example: 'structural', + maxLength: 100, + }) + @IsNotEmpty() + @IsString() + @Length(1, 100) + tagName!: string; + + @ApiPropertyOptional({ + description: 'รหัสสีของแท็ก', + example: '#ff0000', + maxLength: 30, + }) + @IsOptional() + @IsString() + @Length(1, 30) + colorCode?: string; + + @ApiPropertyOptional({ + description: 'คำอธิบายเพิ่มเติมเกี่ยวกับแท็ก', + example: 'แท็กสำหรับคัดกรองเอกสารประเภทโครงสร้าง', + }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/backend/src/modules/tags/entities/correspondence-tag.entity.ts b/backend/src/modules/tags/entities/correspondence-tag.entity.ts new file mode 100644 index 00000000..18f9d138 --- /dev/null +++ b/backend/src/modules/tags/entities/correspondence-tag.entity.ts @@ -0,0 +1,47 @@ +// File: src/modules/tags/entities/correspondence-tag.entity.ts +// Change Log: +// - 2026-05-22: สร้างเอนทิตี CorrespondenceTag สำหรับจัดการความสัมพันธ์ M:N ระหว่าง Correspondence และ Tag (ADR-028) + +import { + Entity, + Column, + PrimaryColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tag } from './tag.entity'; + +/** + * เอนทิตี CorrespondenceTag สำหรับเก็บความสัมพันธ์แบบ M:N ระหว่างเอกสารโต้ตอบและแท็ก + */ +@Entity('correspondence_tags') +export class CorrespondenceTag { + @PrimaryColumn({ name: 'correspondence_id', type: 'int' }) + correspondenceId!: number; + + @PrimaryColumn({ name: 'tag_id', type: 'int' }) + tagId!: number; + + @Column({ name: 'is_ai_suggested', type: 'boolean', default: false }) + isAiSuggested!: boolean; + + @Column({ + name: 'confidence', + type: 'decimal', + precision: 4, + scale: 3, + nullable: true, + }) + confidence!: number | null; + + @Column({ name: 'created_by', type: 'int', nullable: true }) + createdBy!: number | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @ManyToOne(() => Tag, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tag_id' }) + tag?: Tag; +} diff --git a/backend/src/modules/tags/entities/tag.entity.ts b/backend/src/modules/tags/entities/tag.entity.ts new file mode 100644 index 00000000..0bc99f67 --- /dev/null +++ b/backend/src/modules/tags/entities/tag.entity.ts @@ -0,0 +1,53 @@ +// File: src/modules/tags/entities/tag.entity.ts +// Change Log: +// - 2026-05-22: สร้างเอนทิตี Tag สำหรับเป็นตัวแทนตาราง tags ในฐานข้อมูล (ADR-028) + +import { Entity, PrimaryGeneratedColumn, Column, BeforeInsert } from 'typeorm'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { v7 as uuidv7 } from 'uuid'; + +/** + * เอนทิตี Tag สำหรับเก็บข้อมูลแท็กที่ใช้ในการจัดหมวดหมู่เอกสารโต้ตอบ + */ +@Entity('tags') +export class Tag extends BaseEntity { + @PrimaryGeneratedColumn({ name: 'id' }) + id!: number; + + @Column({ + type: 'char', + length: 36, + name: 'public_id', + unique: true, + nullable: false, + comment: 'UUIDv7 สำหรับส่งออกไปนอก API (ADR-019)', + }) + publicId!: string; + + @Column({ name: 'project_id', type: 'int', nullable: true }) + projectId!: number | null; + + @Column({ name: 'tag_name', type: 'varchar', length: 100, nullable: false }) + tagName!: string; + + @Column({ + name: 'color_code', + type: 'varchar', + length: 30, + default: 'default', + }) + colorCode!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description!: string | null; + + @Column({ name: 'created_by', type: 'int', nullable: true }) + createdBy!: number | null; + + @BeforeInsert() + generatePublicId(): void { + if (!this.publicId) { + this.publicId = uuidv7(); + } + } +} diff --git a/backend/src/modules/tags/tags.controller.ts b/backend/src/modules/tags/tags.controller.ts new file mode 100644 index 00000000..e8d8ea1c --- /dev/null +++ b/backend/src/modules/tags/tags.controller.ts @@ -0,0 +1,70 @@ +// File: src/modules/tags/tags.controller.ts +// Change Log: +// - 2026-05-22: เริ่มต้นสร้าง TagsController สำหรับจัดการ Endpoint ของแท็กตาม ADR-028 + +import { + Controller, + Get, + Post, + Body, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { TagsService } from './tags.service'; +import { CreateTagDto } from './dto/create-tag.dto'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { RbacGuard } from '../../common/guards/rbac.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { UuidResolverService } from '../../common/services/uuid-resolver.service'; +import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface'; + +/** + * คอนโทรลเลอร์สำหรับจัดการแท็กโครงการและแท็กระบบ + */ +@ApiTags('Tags') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RbacGuard) +@Controller('tags') +export class TagsController { + constructor( + private readonly tagsService: TagsService, + private readonly uuidResolver: UuidResolverService + ) {} + + /** + * สร้างแท็กใหม่ภายใต้สิทธิ์ tag.create + */ + @Post() + @ApiOperation({ summary: 'Create new tag' }) + @RequirePermission('tag.create') + async create( + @Body() createDto: CreateTagDto, + @Request() req: RequestWithUser + ) { + const resolvedProjectId = createDto.projectId + ? await this.uuidResolver.resolveProjectId(createDto.projectId) + : null; + return this.tagsService.create({ + projectId: resolvedProjectId, + tagName: createDto.tagName, + colorCode: createDto.colorCode, + description: createDto.description, + createdBy: req.user.user_id, + }); + } + + /** + * ค้นหาแท็กทั้งหมดตาม Project ID (UUID) ภายใต้สิทธิ์ tag.view + */ + @Get() + @ApiOperation({ summary: 'Get tags by project' }) + @RequirePermission('tag.view') + async findByProject(@Query('projectId') projectId?: string) { + const resolvedProjectId = projectId + ? await this.uuidResolver.resolveProjectId(projectId) + : null; + return this.tagsService.findByProject(resolvedProjectId); + } +} diff --git a/backend/src/modules/tags/tags.module.ts b/backend/src/modules/tags/tags.module.ts new file mode 100644 index 00000000..bf87fd13 --- /dev/null +++ b/backend/src/modules/tags/tags.module.ts @@ -0,0 +1,21 @@ +// File: src/modules/tags/tags.module.ts +// Change Log: +// - 2026-05-22: เริ่มต้นสร้าง TagsModule สำหรับจัดการแท็กโครงการและจัดหมวดหมู่เอกสารโต้ตอบตาม ADR-028 + +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Tag } from './entities/tag.entity'; +import { CorrespondenceTag } from './entities/correspondence-tag.entity'; +import { TagsService } from './tags.service'; +import { TagsController } from './tags.controller'; + +/** + * โมดูลสำหรับจัดการแท็กโครงการและเอกสารโต้ตอบ (Tags & Correspondence Links) + */ +@Module({ + imports: [TypeOrmModule.forFeature([Tag, CorrespondenceTag])], + controllers: [TagsController], + providers: [TagsService], + exports: [TagsService], +}) +export class TagsModule {} diff --git a/backend/src/modules/tags/tags.service.ts b/backend/src/modules/tags/tags.service.ts new file mode 100644 index 00000000..52cab1f3 --- /dev/null +++ b/backend/src/modules/tags/tags.service.ts @@ -0,0 +1,124 @@ +// File: src/modules/tags/tags.service.ts +// Change Log: +// - 2026-05-22: เริ่มต้นสร้าง TagsService สำหรับจัดการข้อมูลแท็กและเชื่อมโยงกับเอกสารโต้ตอบตาม ADR-028 +// - 2026-05-22: แก้ไข type compilation error ของ projectId ใน findOne และ find โดยใช้ IsNull() + +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, IsNull } from 'typeorm'; +import { Tag } from './entities/tag.entity'; +import { CorrespondenceTag } from './entities/correspondence-tag.entity'; + +/** + * บริการสำหรับจัดการแท็กและการเชื่อมโยงแท็กกับเอกสารโต้ตอบ + */ +@Injectable() +export class TagsService { + private readonly logger = new Logger(TagsService.name); + + constructor( + @InjectRepository(Tag) + private readonly tagRepo: Repository, + @InjectRepository(CorrespondenceTag) + private readonly correspondenceTagRepo: Repository, + private readonly dataSource: DataSource + ) {} + + /** + * สร้างแท็กใหม่ ป้องกันการสร้างแท็กซ้ำโดยทำการ normalize ชื่อแท็กก่อนเสมอ + */ + async create(dto: { + projectId: number | null; + tagName: string; + colorCode?: string; + description?: string | null; + createdBy?: number | null; + }): Promise { + const normalizedName = this.normalize(dto.tagName); + const existing = await this.tagRepo.findOne({ + where: { + projectId: dto.projectId === null ? IsNull() : dto.projectId, + tagName: normalizedName, + }, + }); + if (existing) { + return existing; + } + const tag = this.tagRepo.create({ + projectId: dto.projectId, + tagName: normalizedName, + colorCode: dto.colorCode || 'default', + description: dto.description || null, + createdBy: dto.createdBy || null, + }); + return this.tagRepo.save(tag); + } + + /** + * ค้นหาแท็กทั้งหมดตาม Project ID + */ + async findByProject(projectId: number | null): Promise { + return this.tagRepo.find({ + where: { projectId: projectId === null ? IsNull() : projectId }, + order: { tagName: 'ASC' }, + }); + } + + /** + * ค้นหาหรือสร้างแท็กจากชื่อหลายๆ ชื่อพร้อมกัน (ใช้ตอนประมวลผลผลลัพธ์ของ AI) + */ + async findOrCreateTags( + projectId: number | null, + tagNames: string[], + createdBy?: number | null + ): Promise { + const uniqueNames = Array.from( + new Set(tagNames.map((name) => this.normalize(name))) + ).filter(Boolean); + const result: Tag[] = []; + for (const name of uniqueNames) { + const tag = await this.create({ + projectId, + tagName: name, + createdBy, + }); + result.push(tag); + } + return result; + } + + /** + * ทำความสะอาดและปรับรูปแบบชื่อแท็กให้เป็นตัวพิมพ์เล็กและไม่มีช่องว่างส่วนเกิน + */ + normalize(tagName: string): string { + return tagName.trim().toLowerCase(); + } + + /** + * เชื่อมโยงแท็กกับเอกสารโต้ตอบ (Correspondence) ป้องกันการบันทึกซ้ำซ้อน + */ + async linkToCorrespondence( + correspondenceId: number, + tagId: number, + options?: { + isAiSuggested?: boolean; + confidence?: number; + createdBy?: number; + } + ): Promise { + const exists = await this.correspondenceTagRepo.findOne({ + where: { correspondenceId, tagId }, + }); + if (exists) { + return exists; + } + const link = this.correspondenceTagRepo.create({ + correspondenceId, + tagId, + isAiSuggested: options?.isAiSuggested || false, + confidence: options?.confidence || null, + createdBy: options?.createdBy || null, + }); + return this.correspondenceTagRepo.save(link); + } +} diff --git a/frontend/app/(admin)/admin/migration/review/[id]/page.tsx b/frontend/app/(admin)/admin/migration/review/[id]/page.tsx index 927f0aa5..85cfdf1b 100644 --- a/frontend/app/(admin)/admin/migration/review/[id]/page.tsx +++ b/frontend/app/(admin)/admin/migration/review/[id]/page.tsx @@ -26,6 +26,7 @@ interface MigrationAiIssues { sourceFilePath?: string; keyPoints?: string[]; validationResults?: Array<{ message: string; severity: string }>; + tags?: string[]; } const reviewFormSchema = z.object({ @@ -101,11 +102,9 @@ export default function MigrationReviewPage() { const onSubmit = async (values: ReviewFormValues) => { if (!item) return; - try { setSubmitting(true); - const issues = item.aiIssues || {}; - + const issues = (item.aiIssues || {}) as unknown as MigrationAiIssues; const payload = { documentNumber: values.documentNumber, subject: values.subject, @@ -113,7 +112,7 @@ export default function MigrationReviewPage() { sourceFilePath: issues.sourceFilePath || '', migratedBy: 'SYSTEM_IMPORT', batchId: 'MANUAL_REVIEW_BATCH', - projectId: 1, // Assumption or pulled from store + projectId: 1, documentDate: values.documentDate, issuedDate: values.issuedDate, receivedDate: values.receivedDate, @@ -124,15 +123,12 @@ export default function MigrationReviewPage() { aiConfidence: item.aiConfidence, }, }; - if (!item?.id) { toast.error('Invalid item ID'); return; } - // Mock idempotency key based on timestamp to ensure uniqueness per approval retry const idempotencyKey = `review-${item.id}-${Date.now()}`; await migrationService.approveQueueItem(item.id, payload, idempotencyKey); - toast.success('Document approved and imported successfully'); router.push('/admin/migration'); } catch (error: unknown) { diff --git a/frontend/app/(dashboard)/migration/review/page.tsx b/frontend/app/(dashboard)/migration/review/page.tsx new file mode 100644 index 00000000..754637e9 --- /dev/null +++ b/frontend/app/(dashboard)/migration/review/page.tsx @@ -0,0 +1,167 @@ +// File: app/(dashboard)/migration/review/page.tsx +// Change Log: +// - 2026-05-22: Initial creation of Migration Review page with premium UI, pagination, status tabs, and strictly zero blank lines inside function bodies (T024) + +'use client'; + +import React, { useState } from 'react'; +import { useMigrationReviewQueue } from '@/hooks/use-migration-review'; +import { MigrationReviewStatus } from '@/types/migration'; +import { ReviewQueueTable } from '@/components/migration/review-queue-table'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { ChevronLeft, ChevronRight, RefreshCw, BarChart2, ShieldAlert } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function MigrationReviewPage() { + const [statusFilter, setStatusFilter] = useState(MigrationReviewStatus.PENDING); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + const { data, isLoading, isFetching, refetch } = useMigrationReviewQueue( + statusFilter === 'ALL' ? undefined : statusFilter, + currentPage, + itemsPerPage + ); + const items = data?.items || []; + const totalItems = data?.total || 0; + const totalPages = data?.totalPages || 1; + const handleTabChange = (value: string) => { + setStatusFilter(value as MigrationReviewStatus | 'ALL'); + setCurrentPage(1); + }; + const handlePrevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + const handleNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + return ( +
+
+
+

+ Migration Review Queue +

+

+ จัดการรีวิวเอกสารที่ได้รับการย้ายข้อมูลจากระบบเดิมผ่าน AI Engine และกดยืนยันเพื่อบันทึกเข้าระบบจริง +

+
+
+ +
+
+
+ + + รอการตรวจสอบ (Pending) + + + +
+ {statusFilter === MigrationReviewStatus.PENDING ? totalItems : '-'} +
+

คิวเอกสารที่ต้องการการอนุมัติแบบมีส่วนร่วม

+
+
+ + + นำเข้าเรียบร้อย (Imported) + + + +
+ {statusFilter === MigrationReviewStatus.IMPORTED ? totalItems : '-'} +
+

เอกสารที่นำเข้าสู่ระบบจัดเก็บถาวรแล้ว

+
+
+ + + ปฏิเสธนำเข้า (Rejected) + + + +
+ {statusFilter === MigrationReviewStatus.REJECTED ? totalItems : '-'} +
+

เอกสารที่ปฎิเสธและต้องผ่านการตรวจสอบใหม่

+
+
+ + + จำนวนทั้งหมดในระบบ (Total) + + + +
+ {statusFilter === 'ALL' ? totalItems : '-'} +
+

จำนวนรวมรายการย้ายข้อมูลในระบบคิว

+
+
+
+ + +
+ คิวเอกสารย้ายข้อมูล + เลือกรายการเอกสารเพื่อตรวจสอบความสัมพันธ์และแท็กของโครงการ +
+ + + รอตรวจสอบ + นำเข้าแล้ว + ปฏิเสธ + ทั้งหมด + + +
+ + + {totalPages > 1 && ( +
+
+ แสดงหน้า {currentPage} จาก {totalPages} (ทั้งหมด {totalItems} รายการ) +
+
+ + + {currentPage} + + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/components/migration/review-queue-table.tsx b/frontend/components/migration/review-queue-table.tsx new file mode 100644 index 00000000..7de0bf7f --- /dev/null +++ b/frontend/components/migration/review-queue-table.tsx @@ -0,0 +1,506 @@ +// File: components/migration/review-queue-table.tsx +// Change Log: +// - 2026-05-22: Initial creation of ReviewQueueTable component for US2 (T024) +// - 2026-05-22: Integrated hybrid identifiers and Radix Sheet panel with zero blank lines inside function bodies (T024) + +import React, { useState } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useCommitMigrationReview, useRejectMigrationReview } from '@/hooks/use-migration-review'; +import { useProjects, useOrganizations } from '@/hooks/use-master-data'; +import { MigrationReviewQueueItem, MigrationReviewStatus } from '@/types/migration'; +import { Loader2, Calendar, Tag, AlertCircle, Edit, Check, X, Plus } from 'lucide-react'; + +interface ReviewTag { + name?: string; + tagName?: string; + is_new?: boolean; + isNew?: boolean; +} + +interface ProjectOption { + publicId: string; + projectName: string; + projectCode?: string; +} + +interface OrganizationOption { + publicId: string; + organizationName: string; +} + +const getStringField = (value: Record, key: string): string | undefined => + typeof value[key] === 'string' ? value[key] : undefined; + +const toReviewTag = (value: Record): ReviewTag => ({ + name: getStringField(value, 'name'), + tagName: getStringField(value, 'tagName'), + is_new: typeof value.is_new === 'boolean' ? value.is_new : undefined, + isNew: typeof value.isNew === 'boolean' ? value.isNew : undefined, +}); + +const getTagLabel = (tag: Record): string => + getStringField(tag, 'name') ?? getStringField(tag, 'tagName') ?? ''; + +const getIssueText = (issue: Record): string => + getStringField(issue, 'description') ?? getStringField(issue, 'message') ?? ''; + +interface ReviewQueueTableProps { + items: MigrationReviewQueueItem[]; + isLoading: boolean; +} + +export function ReviewQueueTable({ items, isLoading }: ReviewQueueTableProps) { + const [selectedItem, setSelectedItem] = useState(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [editSubject, setEditSubject] = useState(''); + const [editCategory, setEditCategory] = useState(''); + const [editProjectId, setEditProjectId] = useState(''); + const [editSenderId, setEditSenderId] = useState(''); + const [editReceiverId, setEditReceiverId] = useState(''); + const [editIssuedDate, setEditIssuedDate] = useState(''); + const [editReceivedDate, setEditReceivedDate] = useState(''); + const [editBody, setEditBody] = useState(''); + const [editTags, setEditTags] = useState([]); + const [newTagInput, setNewTagInput] = useState(''); + const commitMutation = useCommitMigrationReview(); + const rejectMutation = useRejectMigrationReview(); + const { data: projects = [] } = useProjects(); + const { data: organizations = [] } = useOrganizations(); + const projectOptions = projects as ProjectOption[]; + const organizationOptions = organizations as OrganizationOption[]; + const handleOpenReview = (item: MigrationReviewQueueItem) => { + setSelectedItem(item); + setEditSubject(item.subject || item.title || ''); + setEditCategory(item.aiSuggestedCategory || 'Correspondence'); + setEditProjectId(String(item.projectId || '')); + setEditSenderId(String(item.senderOrganizationId || '')); + setEditReceiverId(String(item.receiverOrganizationId || '')); + setEditIssuedDate(item.issuedDate ? item.issuedDate.substring(0, 10) : ''); + setEditReceivedDate(item.receivedDate ? item.receivedDate.substring(0, 10) : ''); + setEditBody(item.body || ''); + const tags = Array.isArray(item.extractedTags) + ? item.extractedTags.map((tag) => getTagLabel(tag)).filter(Boolean) + : []; + setEditTags(tags); + setNewTagInput(''); + setIsSheetOpen(true); + }; + const handleAddTag = () => { + if (newTagInput.trim() && !editTags.includes(newTagInput.trim())) { + setEditTags([...editTags, newTagInput.trim()]); + setNewTagInput(''); + } + }; + const handleRemoveTag = (tagToRemove: string) => { + setEditTags(editTags.filter((t) => t !== tagToRemove)); + }; + const handleCommit = async () => { + if (!selectedItem) return; + try { + const idempotencyKey = `migration_review_${selectedItem.publicId}_${Date.now()}`; + await commitMutation.mutateAsync({ + publicId: selectedItem.publicId, + idempotencyKey, + subject: editSubject, + category: editCategory, + projectId: editProjectId || undefined, + senderId: editSenderId || undefined, + receiverId: editReceiverId || undefined, + issuedDate: editIssuedDate || undefined, + receivedDate: editReceivedDate || undefined, + tags: editTags, + body: editBody || undefined, + }); + setIsSheetOpen(false); + setSelectedItem(null); + } catch { + return; + } + }; + const handleReject = async () => { + if (!selectedItem) return; + if (window.confirm('คุณแน่ใจหรือไม่ว่าต้องการปฏิเสธเอกสารนี้?')) { + try { + const queueIntId = selectedItem.id || 0; + await rejectMutation.mutateAsync(queueIntId); + setIsSheetOpen(false); + setSelectedItem(null); + } catch { + return; + } + } + }; + const getStatusBadge = (status: MigrationReviewStatus) => { + const configs: Record = { + [MigrationReviewStatus.PENDING]: { + label: 'รอตรวจสอบ', + className: 'bg-yellow-500/20 text-yellow-500 border-yellow-500/30', + }, + [MigrationReviewStatus.APPROVED]: { + label: 'อนุมัติแล้ว', + className: 'bg-blue-500/20 text-blue-500 border-blue-500/30', + }, + [MigrationReviewStatus.REJECTED]: { + label: 'ปฏิเสธ', + className: 'bg-red-500/20 text-red-500 border-red-500/30', + }, + [MigrationReviewStatus.IMPORTED]: { + label: 'นำเข้าแล้ว', + className: 'bg-green-500/20 text-green-500 border-green-500/30', + }, + }; + const config = configs[status] || { label: status, className: '' }; + return {config.label}; + }; + return ( +
+
+ + + + เลขที่เอกสาร + หัวข้อเอกสาร (Subject) + หมวดหมู่ AI + ความมั่นใจ AI + สถานะ + การกระทำ + + + + {isLoading ? ( + + +
+ + กำลังโหลดรายการรอรีวิว... +
+
+
+ ) : items.length === 0 ? ( + + + ไม่พบรายการที่รอตรวจสอบในคิวขณะนี้ + + + ) : ( + items.map((item) => ( + + {item.documentNumber} + + {item.subject || item.title || 'ไม่มีหัวข้อ'} + + + {item.aiSuggestedCategory || 'Correspondence'} + + + {item.aiConfidence ? `${(Number(item.aiConfidence) * 100).toFixed(1)}%` : '-'} + + {getStatusBadge(item.status)} + + + + + )) + )} +
+
+
+ + + + + + รีวิวการย้ายข้อมูลเอกสาร + + {selectedItem?.documentNumber} + + + + ตรวจสอบ แก้ไขข้อมูล Metadata และยืนยันความถูกต้องเพื่อนำข้อมูลเข้าสู่ระบบจดหมายโต้ตอบจริง + + + + {selectedItem && ( +
+ {selectedItem.aiIssues && selectedItem.aiIssues.length > 0 && ( +
+
+ + ข้อควรระวังจากการตรวจสอบของ AI: +
+
    + {selectedItem.aiIssues.map((issue, idx: number) => ( +
  • + {getIssueText(issue)} +
  • + ))} +
+
+ )} + +
+
+ + setEditSubject(e.target.value)} + placeholder="ป้อนหัวข้อเรื่องภาษาไทยหรืออังกฤษ" + className="w-full border-input" + /> + {selectedItem.originalSubject && selectedItem.originalSubject !== editSubject && ( +

+ หัวข้อเดิมที่ AI ดึงได้: {selectedItem.originalSubject} +

+ )} +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + setEditIssuedDate(e.target.value)} + /> +
+ +
+ + setEditReceivedDate(e.target.value)} + /> +
+
+ +
+ +