// File: src/modules/ai/ai-ingest.service.ts // Change Log // - 2026-05-14: เพิ่ม service สำหรับ Legacy Migration staging queue ตาม ADR-023. import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BusinessException, NotFoundException, ValidationException, } from '../../common/exceptions'; import { FileStorageService } from '../../common/file-storage/file-storage.service'; import { MigrationService } from '../migration/migration.service'; import { AiAuditLog, AiAuditStatus as AiStatus, } from './entities/ai-audit-log.entity'; import { Project } from '../project/entities/project.entity'; import { Organization } from '../organization/entities/organization.entity'; import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; import { AiQueueService } from './ai-queue.service'; import { ApproveLegacyMigrationDto, LegacyMigrationIngestDto, LegacyMigrationQueueQueryDto, LegacyMigrationRecordDto, } from './dto/legacy-migration.dto'; import { MigrationReviewRecord, MigrationReviewRecordStatus, } from './entities/migration-review.entity'; export interface MigrationReviewResponse { publicId: string; batchId: string; originalFileName: string; sourceAttachmentPublicId?: string; extractedMetadata?: Record; confidenceScore?: number; status: MigrationReviewRecordStatus; errorReason?: string; createdAt: Date; updatedAt: Date; } export interface PaginatedMigrationReviewResponse { items: MigrationReviewResponse[]; total: number; page: number; limit: number; totalPages: number; } @Injectable() export class AiIngestService { private readonly logger = new Logger(AiIngestService.name); private readonly maxFileSize = 50 * 1024 * 1024; private readonly allowedMimeTypes = new Set([ 'application/pdf', 'application/x-pdf', 'application/octet-stream', ]); constructor( private readonly configService: ConfigService, private readonly fileStorageService: FileStorageService, private readonly aiQueueService: AiQueueService, private readonly migrationService: MigrationService, @InjectRepository(MigrationReviewRecord) private readonly reviewRepo: Repository, @InjectRepository(AiAuditLog) private readonly auditLogRepo: Repository, @InjectRepository(Project) private readonly projectRepo: Repository, @InjectRepository(Organization) private readonly organizationRepo: Repository, @InjectRepository(CorrespondenceType) private readonly correspondenceTypeRepo: Repository ) {} async ingest( dto: LegacyMigrationIngestDto, files: Express.Multer.File[] ): Promise<{ batchId: string; queued: number; queueJobId?: string }> { const records = this.parseRecords(dto.records); const serviceUserId = this.getServiceUserId(); const createdRecords: MigrationReviewRecord[] = []; const filePublicIds: string[] = []; for (let index = 0; index < files.length; index += 1) { const file = files[index]; this.validateFile(file); const attachment = await this.fileStorageService.upload( file, serviceUserId ); const recordInput = this.matchRecord(records, file.originalname, index); filePublicIds.push(attachment.publicId); createdRecords.push( this.reviewRepo.create({ batchId: dto.batchId, originalFileName: recordInput.originalFileName ?? file.originalname, sourceAttachmentPublicId: attachment.publicId, tempAttachmentId: attachment.id, extractedMetadata: recordInput.extractedMetadata, confidenceScore: recordInput.confidenceScore, status: this.deriveStatus(recordInput), errorReason: recordInput.errorReason, }) ); } if (files.length === 0) { for (const recordInput of records) { createdRecords.push( this.reviewRepo.create({ batchId: dto.batchId, originalFileName: recordInput.originalFileName ?? `${dto.batchId}-record.json`, extractedMetadata: recordInput.extractedMetadata, confidenceScore: recordInput.confidenceScore, status: this.deriveStatus(recordInput), errorReason: recordInput.errorReason, }) ); } } if (createdRecords.length === 0) { throw new ValidationException('At least one file or record is required'); } const saved = await this.reviewRepo.save(createdRecords); const queueJobId = await this.aiQueueService.enqueueIngest({ batchId: dto.batchId, filePublicIds, source: dto.source === 'folder-watcher' ? 'folder-watcher' : 'api', }); this.logger.log( `AI legacy migration batch ${dto.batchId} created ${saved.length} staging records` ); return { batchId: dto.batchId, queued: saved.length, queueJobId }; } async listQueue( query: LegacyMigrationQueueQueryDto ): Promise { const page = query.page ?? 1; const limit = query.limit ?? 20; const qb = this.reviewRepo.createQueryBuilder('record'); if (query.status) { qb.where('record.status = :status', { status: query.status }); } qb.orderBy('record.createdAt', 'DESC') .skip((page - 1) * limit) .take(limit); const [items, total] = await qb.getManyAndCount(); return { items: items.map((item) => this.toResponse(item)), total, page, limit, totalPages: Math.ceil(total / limit), }; } async approve( publicId: string, dto: ApproveLegacyMigrationDto, idempotencyKey: string, userId: number ): Promise<{ record: MigrationReviewResponse; importResult: unknown }> { if (!idempotencyKey) { throw new ValidationException('Idempotency-Key header is required'); } const record = await this.reviewRepo.findOne({ where: { publicId } }); if (!record) { throw new NotFoundException('MigrationReviewRecord', publicId); } if (record.status !== MigrationReviewRecordStatus.PENDING) { throw new BusinessException( 'AI_MIGRATION_RECORD_NOT_PENDING', `Migration review record ${publicId} is ${record.status}`, 'รายการนี้ไม่อยู่ในสถานะรอตรวจสอบ', ['รีเฟรชรายการ staging queue', 'ตรวจสอบสถานะล่าสุดก่อนอนุมัติ'] ); } const project = await this.resolveProject(dto.projectPublicId); const correspondenceType = await this.resolveCorrespondenceType( dto.categoryCode ); const sender = dto.senderOrganizationPublicId ? await this.resolveOrganization(dto.senderOrganizationPublicId) : undefined; const receiver = dto.receiverOrganizationPublicId ? await this.resolveOrganization(dto.receiverOrganizationPublicId) : undefined; const importResult = await this.migrationService.importCorrespondence( { documentNumber: dto.documentNumber, subject: dto.subject, category: correspondenceType.typeCode, migratedBy: 'AI_STAGING_APPROVAL', batchId: record.batchId, projectId: project.id, senderId: sender?.id, receiverId: receiver?.id, issuedDate: dto.issuedDate, receivedDate: dto.receivedDate, body: dto.body, tempAttachmentId: record.tempAttachmentId, aiConfidence: record.confidenceScore === undefined ? undefined : Number(record.confidenceScore), details: { aiSuggestion: record.extractedMetadata, humanOverride: dto.finalMetadata, }, }, idempotencyKey, userId ); record.status = MigrationReviewRecordStatus.IMPORTED; record.extractedMetadata = { ...(record.extractedMetadata ?? {}), humanOverride: dto.finalMetadata ?? {}, }; const saved = await this.reviewRepo.save(record); // T025: บันทึก AuditLog เปรียบเทียบ AI suggestion กับ Human override (ADR-023) await this.saveApprovalAuditLog({ documentPublicId: record.publicId, aiSuggestionJson: record.extractedMetadata, humanOverrideJson: (dto.finalMetadata as Record) ?? {}, confirmedByUserId: userId, confidenceScore: record.confidenceScore === undefined ? undefined : Number(record.confidenceScore), }); return { record: this.toResponse(saved), importResult }; } private parseRecords( records: LegacyMigrationIngestDto['records'] ): LegacyMigrationRecordDto[] { if (!records) return []; if (Array.isArray(records)) return records; try { const parsed = JSON.parse(records) as unknown; if (!Array.isArray(parsed)) { throw new Error('records must be an array'); } return parsed as LegacyMigrationRecordDto[]; } catch (error) { throw new ValidationException( `Invalid records payload: ${error instanceof Error ? error.message : String(error)}` ); } } private matchRecord( records: LegacyMigrationRecordDto[], originalFileName: string, index: number ): LegacyMigrationRecordDto { return ( records.find((record) => record.originalFileName === originalFileName) ?? records[index] ?? {} ); } private deriveStatus( record: LegacyMigrationRecordDto ): MigrationReviewRecordStatus { if (record.status) return record.status; if (record.errorReason) return MigrationReviewRecordStatus.REJECTED; if ( record.confidenceScore !== undefined && Number(record.confidenceScore) < 0.6 ) { return MigrationReviewRecordStatus.REJECTED; } return MigrationReviewRecordStatus.PENDING; } private validateFile(file: Express.Multer.File): void { if (file.size > this.maxFileSize) { throw new ValidationException('File exceeds 50MB limit'); } if (!this.allowedMimeTypes.has(file.mimetype)) { throw new ValidationException(`Unsupported file type: ${file.mimetype}`); } } private getServiceUserId(): number { return this.configService.get('AI_SERVICE_USER_ID') ?? 1; } private async resolveProject(publicId: string): Promise { const project = await this.projectRepo.findOne({ where: { publicId } }); if (!project) throw new NotFoundException('Project', publicId); return project; } private async resolveOrganization(publicId: string): Promise { const organization = await this.organizationRepo.findOne({ where: { publicId }, }); if (!organization) throw new NotFoundException('Organization', publicId); return organization; } private async resolveCorrespondenceType( typeCode: string ): Promise { const type = await this.correspondenceTypeRepo.findOne({ where: [{ typeCode }, { typeName: typeCode }], }); if (!type) throw new NotFoundException('CorrespondenceType', typeCode); return type; } /** T025: บันทึก AuditLog สำหรับการอนุมัติ Human-in-the-loop (ADR-023 Rule 5) */ private async saveApprovalAuditLog(data: { documentPublicId: string; aiSuggestionJson?: Record; humanOverrideJson: Record; confirmedByUserId: number; confidenceScore?: number; }): Promise { try { const log = this.auditLogRepo.create({ documentPublicId: data.documentPublicId, aiModel: 'legacy-migration', status: AiStatus.SUCCESS, aiSuggestionJson: data.aiSuggestionJson, humanOverrideJson: data.humanOverrideJson, confirmedByUserId: data.confirmedByUserId, confidenceScore: data.confidenceScore, }); await this.auditLogRepo.save(log); } catch (err: unknown) { this.logger.error( `Failed to save approval audit log for ${data.documentPublicId}: ${err instanceof Error ? err.message : String(err)}` ); } } private toResponse(record: MigrationReviewRecord): MigrationReviewResponse { return { publicId: record.publicId, batchId: record.batchId, originalFileName: record.originalFileName, sourceAttachmentPublicId: record.sourceAttachmentPublicId, extractedMetadata: record.extractedMetadata, confidenceScore: record.confidenceScore === undefined ? undefined : Number(record.confidenceScore), status: record.status, errorReason: record.errorReason, createdAt: record.createdAt, updatedAt: record.updatedAt, }; } }