diff --git a/backend/src/modules/ai/ai-migration-checkpoint.service.spec.ts b/backend/src/modules/ai/ai-migration-checkpoint.service.spec.ts new file mode 100644 index 00000000..fb18ef11 --- /dev/null +++ b/backend/src/modules/ai/ai-migration-checkpoint.service.spec.ts @@ -0,0 +1,91 @@ +// File: src/modules/ai/ai-migration-checkpoint.service.spec.ts +// Change Log +// - 2026-05-24: เพิ่ม regression tests สำหรับ migration error enum normalization และ job_id logging. + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { AiMigrationCheckpointService } from './ai-migration-checkpoint.service'; +import { MigrationProgress } from './entities/migration-progress.entity'; +import { MigrationReviewRecord } from './entities/migration-review.entity'; + +describe('AiMigrationCheckpointService', () => { + const mockProgressRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + const mockReviewRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + const mockDataSource = { + query: jest.fn(), + manager: { + query: jest.fn(), + }, + }; + + let service: AiMigrationCheckpointService; + + beforeEach(async () => { + jest.clearAllMocks(); + mockDataSource.query.mockResolvedValue([{ insertId: 99 }]); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AiMigrationCheckpointService, + { + provide: getRepositoryToken(MigrationProgress), + useValue: mockProgressRepo, + }, + { + provide: getRepositoryToken(MigrationReviewRecord), + useValue: mockReviewRepo, + }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + service = module.get(AiMigrationCheckpointService); + }); + + it('ควร map AI_JOB_FAILED เป็น API_ERROR และบันทึก job_id', async () => { + await expect( + service.logError({ + batchId: 'C22024-MIGRATION', + documentNumber: 'LCB-RFA-001', + errorType: 'AI_JOB_FAILED', + errorMessage: 'AI job failed', + jobId: 'job-123', + }) + ).resolves.toEqual({ id: 99 }); + + expect(mockDataSource.query).toHaveBeenCalledWith( + expect.stringContaining('job_id'), + [ + 'C22024-MIGRATION', + 'LCB-RFA-001', + 'API_ERROR', + 'AI job failed', + 'job-123', + ] + ); + }); + + it('ควร fallback เป็น UNKNOWN เมื่อ workflow ส่ง error_type ที่ enum ไม่รองรับ', async () => { + await service.logError({ + batchId: 'C22024-MIGRATION', + documentNumber: 'WORKFLOW', + errorType: 'UNSUPPORTED_ERROR', + errorMessage: 'unexpected', + }); + + expect(mockDataSource.query).toHaveBeenCalledWith(expect.any(String), [ + 'C22024-MIGRATION', + 'WORKFLOW', + 'UNKNOWN', + 'unexpected', + null, + ]); + }); +}); diff --git a/backend/src/modules/ai/ai-migration-checkpoint.service.ts b/backend/src/modules/ai/ai-migration-checkpoint.service.ts index 88d6c2e6..5bf8a493 100644 --- a/backend/src/modules/ai/ai-migration-checkpoint.service.ts +++ b/backend/src/modules/ai/ai-migration-checkpoint.service.ts @@ -2,6 +2,7 @@ // Change Log: // - 2026-05-23: สร้าง service จัดการ Migration Checkpoint, Queue และ Error log ผ่าน API (ADR-023A) // - 2026-05-24: เพิ่มฟังก์ชันค้นหาและแปลง UUID เป็นตัวเลข ID จริงใน upsertQueueRecord เพื่อป้องกันการเขียนทับด้วย undefined +// - 2026-05-24: Normalize migration error type และบันทึก jobId เพื่อป้องกัน DB enum reject import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -28,6 +29,35 @@ export interface CheckpointResponse { updatedAt: Date | null; } +type MigrationErrorTypeValue = + | 'FILE_NOT_FOUND' + | 'MISSING_FILENAME' + | 'FILE_ERROR' + | 'AI_PARSE_ERROR' + | 'API_ERROR' + | 'DB_ERROR' + | 'SECURITY' + | 'UNKNOWN'; + +const MIGRATION_ERROR_TYPE_MAP: Readonly< + Record +> = { + AI_JOB_FAILED: 'API_ERROR', + PARSE_ERROR: 'AI_PARSE_ERROR', + TOKEN_EXPIRED: 'API_ERROR', +}; + +const MIGRATION_ERROR_TYPES = new Set([ + 'FILE_NOT_FOUND', + 'MISSING_FILENAME', + 'FILE_ERROR', + 'AI_PARSE_ERROR', + 'API_ERROR', + 'DB_ERROR', + 'SECURITY', + 'UNKNOWN', +]); + @Injectable() export class AiMigrationCheckpointService { private readonly logger = new Logger(AiMigrationCheckpointService.name); @@ -140,19 +170,31 @@ export class AiMigrationCheckpointService { * บันทึก Error Log สำหรับเอกสารที่ประมวลผลไม่สำเร็จ */ async logError(dto: MigrationErrorLogDto): Promise<{ id: number }> { + const errorType = this.normalizeMigrationErrorType(dto.errorType); const result = await this.dataSource.query<{ insertId: number }[]>( - `INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, created_at) - VALUES (?, ?, ?, ?, NOW())`, + `INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, job_id, created_at) + VALUES (?, ?, ?, ?, ?, NOW())`, [ dto.batchId, dto.documentNumber, - dto.errorType ?? 'UNKNOWN', + errorType, dto.errorMessage ?? '', + dto.jobId ?? null, ] ); this.logger.warn( - `Error logged — batchId=${dto.batchId} doc=${dto.documentNumber} type=${dto.errorType}` + `Error logged — batchId=${dto.batchId} doc=${dto.documentNumber} type=${errorType}` ); return { id: result[0]?.insertId ?? 0 }; } + + /** แปลง error_type จาก workflow ให้ตรง enum ของ migration_errors */ + private normalizeMigrationErrorType( + errorType?: string + ): MigrationErrorTypeValue { + const mappedType = errorType + ? (MIGRATION_ERROR_TYPE_MAP[errorType] ?? errorType) + : 'UNKNOWN'; + return MIGRATION_ERROR_TYPES.has(mappedType) ? mappedType : 'UNKNOWN'; + } } diff --git a/backend/src/modules/migration/entities/migration-error.entity.ts b/backend/src/modules/migration/entities/migration-error.entity.ts index b4e0217a..391c0dcd 100644 --- a/backend/src/modules/migration/entities/migration-error.entity.ts +++ b/backend/src/modules/migration/entities/migration-error.entity.ts @@ -38,6 +38,9 @@ export class MigrationError { @Column({ name: 'error_message', type: 'text', nullable: true }) errorMessage?: string; + @Column({ name: 'job_id', length: 100, nullable: true }) + jobId?: string; + @Column({ name: 'raw_ai_response', type: 'text', nullable: true }) rawAiResponse?: string; diff --git a/specs/03-Data-and-Storage/deltas/2026-05-22-drop-migration-tables.rollback.sql b/specs/03-Data-and-Storage/deltas/2026-05-22-drop-migration-tables.rollback.sql index 2ae5dba2..71c480dd 100644 --- a/specs/03-Data-and-Storage/deltas/2026-05-22-drop-migration-tables.rollback.sql +++ b/specs/03-Data-and-Storage/deltas/2026-05-22-drop-migration-tables.rollback.sql @@ -65,9 +65,11 @@ CREATE TABLE IF NOT EXISTS migration_errors ( 'UNKNOWN' ), error_message TEXT, + job_id VARCHAR(100) NULL, raw_ai_response TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_batch_id (batch_id), + INDEX idx_job_id (job_id), INDEX idx_error_type (error_type) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Migration: Error Log'; diff --git a/specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.rollback.sql b/specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.rollback.sql new file mode 100644 index 00000000..4a3fe1dc --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.rollback.sql @@ -0,0 +1,10 @@ +-- File: specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.rollback.sql +-- Change Log: +-- - 2026-05-24: Rollback สำหรับลบ job_id ออกจาก migration_errors + +-- Delta Rollback: ลบคอลัมน์ job_id สำหรับ Migration Error Log +-- Related ADR: ADR-009, ADR-023A, ADR-028 + +ALTER TABLE migration_errors +DROP INDEX idx_migration_errors_job_id, +DROP COLUMN job_id; diff --git a/specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.sql b/specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.sql new file mode 100644 index 00000000..6631508a --- /dev/null +++ b/specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.sql @@ -0,0 +1,11 @@ +-- File: specs/03-Data-and-Storage/deltas/2026-05-24-add-migration-errors-job-id.sql +-- Change Log: +-- - 2026-05-24: เพิ่ม job_id ใน migration_errors เพื่อผูก error log กับ BullMQ AI job + +-- Delta: เพิ่มคอลัมน์ job_id สำหรับ Migration Error Log +-- Related ADR: ADR-009, ADR-023A, ADR-028 + +ALTER TABLE migration_errors +ADD COLUMN job_id VARCHAR(100) NULL COMMENT 'BullMQ Job ID สำหรับ trace error ของ AI migration' +AFTER error_message, +ADD INDEX idx_migration_errors_job_id (job_id); diff --git a/specs/03-Data-and-Storage/lcbp3-v1.9.0-migration.sql b/specs/03-Data-and-Storage/lcbp3-v1.9.0-migration.sql index 31f591a4..185d8888 100644 --- a/specs/03-Data-and-Storage/lcbp3-v1.9.0-migration.sql +++ b/specs/03-Data-and-Storage/lcbp3-v1.9.0-migration.sql @@ -69,9 +69,11 @@ CREATE TABLE IF NOT EXISTS migration_errors ( 'UNKNOWN' ), error_message TEXT, + job_id VARCHAR(100) NULL, raw_ai_response TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_batch_id (batch_id), + INDEX idx_job_id (job_id), INDEX idx_error_type (error_type) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Migration: Error Log (ลบได้หลัง Migration เสร็จ)'; diff --git a/specs/03-Data-and-Storage/n8n.workflow.v2.json b/specs/03-Data-and-Storage/n8n.workflow.v2.json index ed42f0ce..b3f3778f 100644 --- a/specs/03-Data-and-Storage/n8n.workflow.v2.json +++ b/specs/03-Data-and-Storage/n8n.workflow.v2.json @@ -574,7 +574,7 @@ }, { "parameters": { - "jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/error_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n const line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.error_type || 'UNKNOWN'),\n esc(item.json.error || item.json.parse_error),\n esc(item.json.job_id || '')\n ].join(',') + '\\n';\n fs.appendFileSync(csvPath, line, 'utf8');\n}\n\nreturn items;" + "jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst ERROR_TYPE_MAP = {\n AI_JOB_FAILED: 'API_ERROR',\n PARSE_ERROR: 'AI_PARSE_ERROR',\n TOKEN_EXPIRED: 'API_ERROR',\n};\nconst ALLOWED_ERROR_TYPES = new Set([\n 'FILE_NOT_FOUND',\n 'MISSING_FILENAME',\n 'FILE_ERROR',\n 'AI_PARSE_ERROR',\n 'API_ERROR',\n 'DB_ERROR',\n 'SECURITY',\n 'UNKNOWN',\n]);\nconst normalizeErrorType = (type) => {\n const mappedType = ERROR_TYPE_MAP[type] || type || 'UNKNOWN';\n return ALLOWED_ERROR_TYPES.has(mappedType) ? mappedType : 'UNKNOWN';\n};\n\nconst csvPath = `${config.LOG_PATH}/error_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n item.json.document_number = item.json.document_number || 'WORKFLOW';\n item.json.error_type = normalizeErrorType(item.json.error_type);\n item.json.error = item.json.error || item.json.parse_error || item.json.message || '';\n item.json.job_id = item.json.job_id || '';\n\n const line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.error_type),\n esc(item.json.error),\n esc(item.json.job_id)\n ].join(',') + '\\n';\n fs.appendFileSync(csvPath, line, 'utf8');\n}\n\nreturn items;" }, "id": "511428dc-3aad-4de1-a9dc-9a87c791371e", "name": "Log Error to CSV", @@ -605,7 +605,7 @@ }, "sendBody": true, "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, documentNumber: $json.document_number || 'WORKFLOW', errorType: $json.error_type || 'UNKNOWN', errorMessage: $json.error || $json.parse_error || $json.message || '', jobId: $json.job_id || '' }) }}", + "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, documentNumber: $json.document_number, errorType: $json.error_type, errorMessage: $json.error, jobId: $json.job_id || '' }) }}", "options": { "timeout": 10000 }