690524:2148 ADR-028-228-migration #05
This commit is contained in:
@@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// Change Log:
|
// Change Log:
|
||||||
// - 2026-05-23: สร้าง service จัดการ Migration Checkpoint, Queue และ Error log ผ่าน API (ADR-023A)
|
// - 2026-05-23: สร้าง service จัดการ Migration Checkpoint, Queue และ Error log ผ่าน API (ADR-023A)
|
||||||
// - 2026-05-24: เพิ่มฟังก์ชันค้นหาและแปลง UUID เป็นตัวเลข ID จริงใน upsertQueueRecord เพื่อป้องกันการเขียนทับด้วย undefined
|
// - 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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@@ -28,6 +29,35 @@ export interface CheckpointResponse {
|
|||||||
updatedAt: Date | null;
|
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<string, MigrationErrorTypeValue>
|
||||||
|
> = {
|
||||||
|
AI_JOB_FAILED: 'API_ERROR',
|
||||||
|
PARSE_ERROR: 'AI_PARSE_ERROR',
|
||||||
|
TOKEN_EXPIRED: 'API_ERROR',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIGRATION_ERROR_TYPES = new Set<MigrationErrorTypeValue>([
|
||||||
|
'FILE_NOT_FOUND',
|
||||||
|
'MISSING_FILENAME',
|
||||||
|
'FILE_ERROR',
|
||||||
|
'AI_PARSE_ERROR',
|
||||||
|
'API_ERROR',
|
||||||
|
'DB_ERROR',
|
||||||
|
'SECURITY',
|
||||||
|
'UNKNOWN',
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AiMigrationCheckpointService {
|
export class AiMigrationCheckpointService {
|
||||||
private readonly logger = new Logger(AiMigrationCheckpointService.name);
|
private readonly logger = new Logger(AiMigrationCheckpointService.name);
|
||||||
@@ -140,19 +170,31 @@ export class AiMigrationCheckpointService {
|
|||||||
* บันทึก Error Log สำหรับเอกสารที่ประมวลผลไม่สำเร็จ
|
* บันทึก Error Log สำหรับเอกสารที่ประมวลผลไม่สำเร็จ
|
||||||
*/
|
*/
|
||||||
async logError(dto: MigrationErrorLogDto): Promise<{ id: number }> {
|
async logError(dto: MigrationErrorLogDto): Promise<{ id: number }> {
|
||||||
|
const errorType = this.normalizeMigrationErrorType(dto.errorType);
|
||||||
const result = await this.dataSource.query<{ insertId: number }[]>(
|
const result = await this.dataSource.query<{ insertId: number }[]>(
|
||||||
`INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, created_at)
|
`INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, job_id, created_at)
|
||||||
VALUES (?, ?, ?, ?, NOW())`,
|
VALUES (?, ?, ?, ?, ?, NOW())`,
|
||||||
[
|
[
|
||||||
dto.batchId,
|
dto.batchId,
|
||||||
dto.documentNumber,
|
dto.documentNumber,
|
||||||
dto.errorType ?? 'UNKNOWN',
|
errorType,
|
||||||
dto.errorMessage ?? '',
|
dto.errorMessage ?? '',
|
||||||
|
dto.jobId ?? null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
this.logger.warn(
|
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 };
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export class MigrationError {
|
|||||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'job_id', length: 100, nullable: true })
|
||||||
|
jobId?: string;
|
||||||
|
|
||||||
@Column({ name: 'raw_ai_response', type: 'text', nullable: true })
|
@Column({ name: 'raw_ai_response', type: 'text', nullable: true })
|
||||||
rawAiResponse?: string;
|
rawAiResponse?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,11 @@ CREATE TABLE IF NOT EXISTS migration_errors (
|
|||||||
'UNKNOWN'
|
'UNKNOWN'
|
||||||
),
|
),
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
|
job_id VARCHAR(100) NULL,
|
||||||
raw_ai_response TEXT,
|
raw_ai_response TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_batch_id (batch_id),
|
INDEX idx_batch_id (batch_id),
|
||||||
|
INDEX idx_job_id (job_id),
|
||||||
INDEX idx_error_type (error_type)
|
INDEX idx_error_type (error_type)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Migration: Error Log';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Migration: Error Log';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -69,9 +69,11 @@ CREATE TABLE IF NOT EXISTS migration_errors (
|
|||||||
'UNKNOWN'
|
'UNKNOWN'
|
||||||
),
|
),
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
|
job_id VARCHAR(100) NULL,
|
||||||
raw_ai_response TEXT,
|
raw_ai_response TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_batch_id (batch_id),
|
INDEX idx_batch_id (batch_id),
|
||||||
|
INDEX idx_job_id (job_id),
|
||||||
INDEX idx_error_type (error_type)
|
INDEX idx_error_type (error_type)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Migration: Error Log (ลบได้หลัง Migration เสร็จ)';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = 'Migration: Error Log (ลบได้หลัง Migration เสร็จ)';
|
||||||
|
|
||||||
|
|||||||
@@ -574,7 +574,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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",
|
"id": "511428dc-3aad-4de1-a9dc-9a87c791371e",
|
||||||
"name": "Log Error to CSV",
|
"name": "Log Error to CSV",
|
||||||
@@ -605,7 +605,7 @@
|
|||||||
},
|
},
|
||||||
"sendBody": true,
|
"sendBody": true,
|
||||||
"specifyBody": "json",
|
"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": {
|
"options": {
|
||||||
"timeout": 10000
|
"timeout": 10000
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user