@@ -0,0 +1,582 @@
{
"name" : "LCBP3 Migration Workflow v2.0.0" ,
"nodes" : [
{
"parameters" : {
"formTitle" : "LCBP3 Migration - ตั้งค่าก่อนรัน" ,
"formDescription" : "กรุณาตั้งค่า Batch และ Excel file ก่อนรัน Migration" ,
"formFields" : {
"values" : [
{
"fieldLabel" : "Batch Size" ,
"fieldType" : "number" ,
"placeholder" : "10"
} ,
{
"fieldLabel" : "Excel File Path" ,
"placeholder" : "/home/node/.n8n-files/staging_ai/C22024.xlsx"
}
]
} ,
"options" : { }
} ,
"id" : "4609ab68-f7e4-4800-ad39-19ce32de60d0" ,
"name" : "Form Trigger" ,
"type" : "n8n-nodes-base.formTrigger" ,
"typeVersion" : 2.2 ,
"position" : [ 31024 , 13504 ] ,
"webhookId" : "e164a362-0c6b-4243-a5ad-b325aa943f4f" ,
"notes" : "เปิด URL เพื่อตั้งค่าก่อนรัน"
} ,
{
"parameters" : {
"jsCode" : "// ============================================\n// CONFIGURATION v2.0 — ADR-023A Compliant\n// n8n เรียกแค่ DMS Backend API เท่านั้น — ห้ามเรียก Ollama โดยตรง\n// ============================================\nconst formData = $('Form Trigger').first()?.json || {};\nconst batchSizeInput = parseInt(String(formData['Batch Size'] || '0'));\nconst excelFileInput = String(formData['Excel File Path'] || '').trim();\n\nconst BATCH_ID = (() => {\n const d = new Date(Date.now() + 7 * 3600000);\n const s = d.toISOString();\n return s.substring(0,10).replace(/-/g,'') + ':' + s.substring(11,16).replace(/:/g,'');\n})();\n\nconst CONFIG = {\n // Backend Settings\n BACKEND_URL: 'https://backend.np-dms.work',\n MIGRATION_TOKEN: 'Bearer YOUR_MIGRATION_TOKEN_HERE', // 🔴 เปลี่ยน\n\n // Batch Settings\n BATCH_SIZE: batchSizeInput > 0 ? batchSizeInput : 10,\n BATCH_ID: BATCH_ID,\n DELAY_MS: 2000,\n\n // AI Job Settings (ADR-023A)\n AI_JOB_POLL_INTERVAL_MS: 5000,\n AI_JOB_TIMEOUT_MS: 120000,\n\n // Confidence Thresholds\n CONFIDENCE_HIGH: 0.85,\n CONFIDENCE_LOW: 0.60,\n\n // Paths (Container paths — ต้องตรงกับ volume mount ใน docker-compose)\n EXCEL_FILE: excelFileInput || '/home/node/.n8n-files/staging_ai/C22024.xlsx',\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai',\n LOG_PATH: '/home/node/.n8n-files/migration_logs',\n};\n\nreturn [{ json: { config: CONFIG } }];"
} ,
"id" : "8f1d3378-cca6-48b6-99db-693e46ac81ef" ,
"name" : "Set Configuration" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 31216 , 13504 ] ,
"notes" : "กำหนดค่า Configuration ทั้งหมด — แก้ไขที่นี่เท่านั้น (ไม่มี Ollama config แล้ว)"
} ,
{
"parameters" : {
"url" : "={{$('Set Configuration').first().json.config.BACKEND_URL}}/health" ,
"options" : {
"timeout" : 5000
}
} ,
"id" : "60e81de6-e9b2-4bff-afcc-bef9d5b959b5" ,
"name" : "Check Backend Health" ,
"type" : "n8n-nodes-base.httpRequest" ,
"typeVersion" : 4.1 ,
"position" : [ 31392 , 13504 ] ,
"onError" : "continueErrorOutput" ,
"notes" : "ตรวจสอบ Backend พร้อมใช้งาน"
} ,
{
"parameters" : {
"url" : "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/auth/me" ,
"sendHeaders" : true ,
"headerParameters" : {
"parameters" : [
{
"name" : "Authorization" ,
"value" : "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
}
]
} ,
"options" : {
"timeout" : 5000
}
} ,
"id" : "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ,
"name" : "Validate Token" ,
"type" : "n8n-nodes-base.httpRequest" ,
"typeVersion" : 4.1 ,
"position" : [ 31392 , 13696 ] ,
"onError" : "continueErrorOutput" ,
"notes" : "FR-010a: ตรวจสอบ MIGRATION_TOKEN ก่อนเริ่ม Batch"
} ,
{
"parameters" : {
"url" : "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/correspondence-types" ,
"sendHeaders" : true ,
"headerParameters" : {
"parameters" : [
{
"name" : "Authorization" ,
"value" : "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
}
]
} ,
"options" : {
"timeout" : 10000
}
} ,
"id" : "6c6679b4-85f3-4c2c-ac8e-4281d6ae61f6" ,
"name" : "Fetch Categories" ,
"type" : "n8n-nodes-base.httpRequest" ,
"typeVersion" : 4.1 ,
"position" : [ 31568 , 13504 ] ,
"notes" : "ดึง Categories จาก Backend"
} ,
{
"parameters" : {
"url" : "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/tags" ,
"sendHeaders" : true ,
"headerParameters" : {
"parameters" : [
{
"name" : "Authorization" ,
"value" : "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
}
]
} ,
"options" : {
"timeout" : 10000
}
} ,
"id" : "98b9159a-f21d-4b33-9524-058a78ccfc93" ,
"name" : "Fetch Tags" ,
"type" : "n8n-nodes-base.httpRequest" ,
"typeVersion" : 4.1 ,
"position" : [ 31568 , 13696 ] ,
"notes" : "ดึง Tags ที่มีอยู่แล้วจาก Backend"
} ,
{
"parameters" : {
"jsCode" : "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\ntry {\n if (!fs.existsSync(config.EXCEL_FILE)) {\n throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n }\n if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n }\n \n const files = fs.readdirSync(config.SOURCE_PDF_DIR);\n \n // Check write permission to log path\n fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString());\n \n // Grab categories\n let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n try {\n const upstreamData = $('Fetch Categories').first()?.json?.data;\n if (upstreamData && Array.isArray(upstreamData)) {\n categories = upstreamData.map(c => c.name || c.type || c);\n }\n } catch(e) {}\n \n // Grab existing tags\n let existingTags = [];\n try {\n const tagData = $('Fetch Tags').first()?.json?.data || [];\n existingTags = Array.isArray(tagData)\n ? tagData.map(t => ({ publicId: t.id, tagName: t.tag_name || t.name || '' })).filter(t => t.tagName)\n : [];\n } catch(e) {}\n \n return [{ json: { \n preflight_ok: true, \n pdf_count_in_source: files.length,\n excel_target: config.EXCEL_FILE,\n system_categories: categories,\n existing_tags: existingTags,\n timestamp: new Date().toISOString()\n }}];\n} catch (err) {\n throw new Error(`Pre-flight check failed: ${err.message}`);\n}"
} ,
"id" : "910b13e2-994a-4fb6-bca1-637e1628c586" ,
"name" : "File Mount Check" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 31744 , 13504 ] ,
"notes" : "ตรวจสอบ File System + รวบรวม master data สำหรับ AI payload"
} ,
{
"parameters" : {
"fileSelector" : "={{ $json.excel_target }}" ,
"options" : { }
} ,
"id" : "063bcef1-791a-4923-a659-8b9a0ba3e336" ,
"name" : "Read Excel Binary" ,
"type" : "n8n-nodes-base.readWriteFile" ,
"typeVersion" : 1 ,
"position" : [ 31920 , 13504 ] ,
"notes" : "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ"
} ,
{
"parameters" : {
"options" : { }
} ,
"id" : "e07efdde-b9b1-402a-ba01-44175982749b" ,
"name" : "Read Excel" ,
"type" : "n8n-nodes-base.spreadsheetFile" ,
"typeVersion" : 2 ,
"position" : [ 31920 , 13696 ] ,
"notes" : "แปลงข้อมูล Excel เป็น JSON Data"
} ,
{
"parameters" : {
"operation" : "executeQuery" ,
"query" : "SELECT last_processed_index, status FROM migration_progress WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1" ,
"options" : { }
} ,
"id" : "a83f8598-72fd-4cc8-9d98-1ea3cb3b42df" ,
"name" : "Read Checkpoint" ,
"type" : "n8n-nodes-base.mySql" ,
"typeVersion" : 2.4 ,
"position" : [ 32096 , 13504 ] ,
"alwaysOutputData" : true ,
"credentials" : {
"mySql" : {
"id" : "CHHfbKhMacNo03V4" ,
"name" : "MySQL account"
}
} ,
"onError" : "continueErrorOutput" ,
"notes" : "อ่านตำแหน่งล่าสุดที่ประมวลผล"
} ,
{
"parameters" : {
"jsCode" : "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.last_processed_index || 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, config.BATCH_SIZE);\n\n// Exit condition: ไม่มี records เหลือ\nif (currentBatch.length === 0) {\n return [{ json: { batch_complete: true, total_processed: startIndex } }];\n}\n\nconst normalize = (str) => {\n if (!str) return '';\n return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n const getVal = (possibleKeys) => {\n const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n if (exactMatch) return item[exactMatch];\n const lowerTrimmedKeys = Object.keys(item).map(k => ({ original: k, parsed: k.toLowerCase().trim() }));\n for (const pk of possibleKeys) {\n const found = lowerTrimmedKeys.find(k => k.parsed === pk.toLowerCase().trim());\n if (found) return item[found.original];\n }\n return '';\n };\n\n const docNum = getVal(['document_number', 'correspondence_number', 'Document Number', 'Corr. No.']);\n const excelFileName = getVal(['File name', 'file_name', 'File Name', 'filename']);\n \n if (!excelFileName) {\n throw new Error(`Missing 'File name' column for row ${i + startIndex + 1}, document: ${docNum}`);\n }\n \n return {\n json: {\n batch_complete: false,\n document_number: normalize(docNum),\n title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n excel_revision: getVal(['revision', 'Revision', 'rev']) || 1,\n original_index: startIndex + i,\n batch_id: config.BATCH_ID,\n file_name: normalize(excelFileName),\n issued_date: normalize(getVal(['issued_date', 'Issued_date', 'Issued Date', 'date', 'Date'])),\n received_date: normalize(getVal(['received_date', 'Received_date', 'Received Date'])),\n sender: normalize(getVal(['sender', 'Sender', 'From', 'from'])),\n receiver: normalize(getVal(['receiver', 'Receiver', 'To', 'to'])),\n }\n };\n});"
} ,
"id" : "80845e32-c283-4e9f-af73-6339d675fb38" ,
"name" : "Process Batch + Encoding" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 32272 , 13504 ] ,
"alwaysOutputData" : true ,
"notes" : "ตัด Batch + Normalize UTF-8 + Exit condition เมื่อ records หมด"
} ,
{
"parameters" : {
"jsCode" : "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\n// ตรวจสอบ exit condition\nif (items[0]?.json?.batch_complete === true) {\n return items;\n}\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n const fileName = item.json?.file_name;\n if (!fileName) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME' } });\n continue;\n }\n \n let safeName = path.basename(String(fileName)).normalize('NFC');\n if (!safeName.toLowerCase().endsWith('.pdf')) safeName += '.pdf';\n const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n \n if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY' } });\n continue;\n }\n \n try {\n if (fs.existsSync(filePath)) {\n const stats = fs.statSync(filePath);\n validated.push({ ...item, json: { ...item.json, file_valid: true, file_path: filePath, file_size: stats.size } });\n } else {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND' } });\n }\n } catch (err) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR' } });\n }\n}\n\nreturn [...validated, ...errors];"
} ,
"id" : "2183d687-4708-4d77-a0a9-13ccf29baf69" ,
"name" : "File Validator" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 32448 , 13504 ] ,
"notes" : "ตรวจสอบไฟล์ PDF ใน Directory"
} ,
{
"parameters" : {
"fileSelector" : "={{ $json.file_path }}" ,
"options" : { }
} ,
"id" : "4fd3133e-39e1-4860-95c7-3e87ee43ed51" ,
"name" : "Read PDF File" ,
"type" : "n8n-nodes-base.readWriteFile" ,
"typeVersion" : 1 ,
"position" : [ 32624 , 13504 ] ,
"onError" : "continueErrorOutput" ,
"notes" : "อ่าน PDF Binary เพื่อเตรียม Upload"
} ,
{
"parameters" : {
"method" : "POST" ,
"url" : "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/storage/upload" ,
"sendHeaders" : true ,
"headerParameters" : {
"parameters" : [
{
"name" : "Authorization" ,
"value" : "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
}
]
} ,
"sendBody" : true ,
"contentType" : "multipart-form-data" ,
"bodyParameters" : {
"parameters" : [
{
"parameterType" : "formBinaryData" ,
"name" : "file" ,
"inputDataFieldName" : "data"
}
]
} ,
"options" : {
"timeout" : 60000
}
} ,
"id" : "b1c2d3e4-f5a6-7890-bcde-f12345678901" ,
"name" : "Upload PDF to Backend" ,
"type" : "n8n-nodes-base.httpRequest" ,
"typeVersion" : 4.2 ,
"position" : [ 32800 , 13504 ] ,
"onError" : "continueErrorOutput" ,
"notes" : "ADR-023A: อัพโหลด PDF เข้า Backend Temp Storage → รับ temp_attachment_id"
} ,
{
"parameters" : {
"jsCode" : "const config = $('Set Configuration').first().json.config;\nconst uploadResponse = $input.first()?.json || {};\nconst metaItem = $('File Validator').all()[$input.context?.itemIndex ?? 0]?.json || {};\nconst mountCheckData = $('File Mount Check').first()?.json || {};\n\nconst tempAttachmentId = uploadResponse?.data?.id || uploadResponse?.id;\nif (!tempAttachmentId) {\n throw new Error(`Upload failed — no temp_attachment_id returned for document: ${metaItem.document_number}`);\n}\n\n// สร้าง SubmitAiJobDto ตาม Backend DTO\nconst submitPayload = {\n type: 'migrate-document',\n payload: {\n tempAttachmentId: String(tempAttachmentId),\n documentNumber: String(metaItem.document_number || ''),\n title: String(metaItem.title || ''),\n batchId: String(config.BATCH_ID),\n existingTags: mountCheckData.existing_tags || [],\n systemCategories: mountCheckData.system_categories || [],\n }\n};\n\n// Idempotency-Key: deterministic format ตาม FR-001a\nconst idempotencyKey = `${config.BATCH_ID}:${metaItem.document_number}`;\n\nreturn [{\n json: {\n ...metaItem,\n temp_attachment_id: tempAttachmentId,\n submit_payload: submitPayload,\n idempotency_key: idempotencyKey,\n }\n}];"
} ,
"id" : "c2d3e4f5-a6b7-8901-cdef-234567890123" ,
"name" : "Build AI Job Payload" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 32976 , 13504 ] ,
"notes" : "สร้าง SubmitAiJobDto payload + Idempotency-Key ตาม FR-001a"
} ,
{
"parameters" : {
"method" : "POST" ,
"url" : "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/jobs" ,
"sendHeaders" : true ,
"headerParameters" : {
"parameters" : [
{
"name" : "Authorization" ,
"value" : "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
} ,
{
"name" : "Idempotency-Key" ,
"value" : "={{ $json.idempotency_key }}"
}
]
} ,
"sendBody" : true ,
"specifyBody" : "json" ,
"jsonBody" : "={{ $json.submit_payload }}" ,
"options" : {
"timeout" : 30000
}
} ,
"id" : "d3e4f5a6-b7c8-9012-defa-345678901234" ,
"name" : "Submit AI Job" ,
"type" : "n8n-nodes-base.httpRequest" ,
"typeVersion" : 4.1 ,
"position" : [ 33152 , 13504 ] ,
"onError" : "continueErrorOutput" ,
"notes" : "ADR-023A: POST /api/ai/jobs — Backend จัดการ Ollama ผ่าน BullMQ"
} ,
{
"parameters" : {
"jsCode" : "// Poll GET /api/ai/jobs/{jobId} ทุก 5 วินาที จน status = completed หรือ timeout 120 วินาที\nconst config = $('Set Configuration').first().json.config;\nconst submitResponse = $input.first()?.json || {};\nconst originalMeta = $('Build AI Job Payload').all()[$input.context?.itemIndex ?? 0]?.json || {};\n\nconst jobId = submitResponse?.jobId || submitResponse?.data?.jobId;\nif (!jobId) {\n // FR-010b: ถ้า 401 → mark TOKEN_EXPIRED\n if (submitResponse?.statusCode === 401 || submitResponse?.error?.status === 401) {\n throw new Error('TOKEN_EXPIRED: 401 Unauthorized — กรุณา renew MIGRATION_TOKEN แล้ว resume');\n }\n throw new Error(`Submit AI Job failed — no jobId returned for: ${originalMeta.document_number}`);\n}\n\nconst pollIntervalMs = config.AI_JOB_POLL_INTERVAL_MS || 5000;\nconst timeoutMs = config.AI_JOB_TIMEOUT_MS || 120000;\nconst startTime = Date.now();\n\nwhile (true) {\n if (Date.now() - startTime > timeoutMs) {\n throw new Error(`AI Job timeout after ${timeoutMs}ms for jobId: ${jobId}, document: ${originalMeta.document_number}`);\n }\n \n const statusResponse = await $helpers.httpRequest({\n method: 'GET',\n url: `${config.BACKEND_URL}/api/ai/jobs/${jobId}`,\n headers: { 'Authorization': config.MIGRATION_TOKEN },\n json: true\n });\n \n const status = statusResponse?.status || statusResponse?.data?.status;\n \n if (status === 'completed') {\n return [{\n json: {\n ...originalMeta,\n job_id: jobId,\n ai_result: statusResponse?.result || statusResponse?.data?.result || {},\n ai_status: 'completed',\n }\n }];\n }\n \n if (status === 'failed') {\n return [{\n json: {\n ...originalMeta,\n job_id: jobId,\n ai_status: 'failed',\n parse_error: `AI Job failed: ${JSON.stringify(statusResponse?.error || statusResponse?.data?.error || 'unknown')}`,\n error_type: 'AI_JOB_FAILED',\n route_index: 3\n }\n }];\n }\n \n // status = 'processing' — รอแล้ว poll ใหม่\n await new Promise(resolve => setTimeout(resolve, pollIntervalMs));\n}"
} ,
"id" : "e4f5a6b7-c8d9-0123-efab-456789012345" ,
"name" : "Poll AI Job Status" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 33328 , 13504 ] ,
"notes" : "Poll GET /api/ai/jobs/{jobId} ทุก 5 วินาที (timeout 120 วินาที)"
} ,
{
"parameters" : {
"jsCode" : "const config = $('Set Configuration').first().json.config;\nconst items = $input.all();\nconst results = [];\n\nconst CATEGORY_TO_TYPE_CODE = {\n 'Correspondence': 'LETTER',\n 'RFA': 'RFA',\n 'Transmittal': 'TRANSMITTAL',\n 'Drawing': 'OTHER',\n 'Report': 'OTHER',\n 'Other': 'OTHER',\n};\n\nfor (const item of items) {\n const data = item.json;\n \n // ถ้า poll ส่ง error กลับมาโดยตรง (route_index = 3)\n if (data.route_index === 3 || data.ai_status === 'failed') {\n results.push({ json: { ...data, route_index: 3 } });\n continue;\n }\n \n const ai = data.ai_result || {};\n \n if (!ai || typeof ai !== 'object') {\n results.push({ json: { ...data, parse_error: 'Empty or invalid ai_result', error_type: 'PARSE_ERROR', route_index: 3 } });\n continue;\n }\n \n // Enum Validation for Category\n const systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n let finalCategory = ai.suggested_category || ai.category || 'Correspondence';\n if (!systemCategories.includes(finalCategory)) {\n finalCategory = String(data.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n const typeCode = CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\n \n // Tag normalization — is_new flag\n const suggestedTags = Array.isArray(ai.suggested_tags)\n ? ai.suggested_tags.map(t => ({\n tagName: String(t.tagName || t.tag_name || ''),\n publicId: t.publicId || t.public_id || undefined,\n isNew: Boolean(t.isNew ?? t.is_new ?? !t.publicId),\n colorCode: t.colorCode || t.color_code || 'default',\n })).filter(t => t.tagName)\n : [];\n \n const confidence = Number(ai.confidence || 0);\n \n const normalizedAi = {\n ...ai,\n suggested_category: finalCategory,\n type_code: typeCode,\n confidence,\n suggested_tags: suggestedTags,\n };\n \n let route_index;\n let review_reason = '';\n let reject_reason = '';\n \n if (confidence >= config.CONFIDENCE_HIGH && ai.is_valid !== false) {\n route_index = 0; // Auto Ready\n } else if (confidence >= config.CONFIDENCE_LOW) {\n route_index = 1; // Flagged\n review_reason = `Confidence ${confidence.toFixed(2)} < ${config.CONFIDENCE_HIGH}`;\n } else {\n route_index = 2; // Rejected\n reject_reason = ai.is_valid === false ? 'AI marked invalid' : `Confidence ${confidence.toFixed(2)} < ${config.CONFIDENCE_LOW}`;\n }\n \n results.push({\n json: {\n ...data,\n ai_result: normalizedAi,\n route_index,\n review_reason,\n reject_reason,\n }\n });\n}\n\nreturn results;"
} ,
"id" : "6716162f-1129-4552-a05f-a08ac115fe10" ,
"name" : "Parse & Validate AI Response" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 33504 , 13504 ] ,
"notes" : "Parse AI result + Confidence routing prep + Tag normalization (isNew flag)"
} ,
{
"parameters" : {
"rules" : {
"values" : [
{
"conditions" : {
"options" : { "caseSensitive" : true , "leftValue" : "" , "typeValidation" : "strict" , "version" : 2 } ,
"conditions" : [ { "leftValue" : "={{ $json.route_index }}" , "rightValue" : 0 , "operator" : { "type" : "number" , "operation" : "equals" , "singleValue" : true } } ] ,
"combinator" : "and"
} ,
"renameOutput" : true ,
"outputKey" : "Auto Ready"
} ,
{
"conditions" : {
"options" : { "caseSensitive" : true , "leftValue" : "" , "typeValidation" : "strict" , "version" : 2 } ,
"conditions" : [ { "leftValue" : "={{ $json.route_index }}" , "rightValue" : 1 , "operator" : { "type" : "number" , "operation" : "equals" , "singleValue" : true } } ] ,
"combinator" : "and"
} ,
"renameOutput" : true ,
"outputKey" : "Review Flagged"
} ,
{
"conditions" : {
"options" : { "caseSensitive" : true , "leftValue" : "" , "typeValidation" : "strict" , "version" : 2 } ,
"conditions" : [ { "leftValue" : "={{ $json.route_index }}" , "rightValue" : 2 , "operator" : { "type" : "number" , "operation" : "equals" , "singleValue" : true } } ] ,
"combinator" : "and"
} ,
"renameOutput" : true ,
"outputKey" : "Rejected"
} ,
{
"conditions" : {
"options" : { "caseSensitive" : true , "leftValue" : "" , "typeValidation" : "strict" , "version" : 2 } ,
"conditions" : [ { "leftValue" : "={{ $json.route_index }}" , "rightValue" : 3 , "operator" : { "type" : "number" , "operation" : "equals" , "singleValue" : true } } ] ,
"combinator" : "and"
} ,
"renameOutput" : true ,
"outputKey" : "Error Log"
}
]
} ,
"options" : { }
} ,
"id" : "65f0bb6c-496a-4409-8b88-3132866cf9a4" ,
"name" : "Route by Confidence" ,
"type" : "n8n-nodes-base.switch" ,
"typeVersion" : 3.2 ,
"position" : [ 33680 , 13504 ]
} ,
{
"parameters" : {
"operation" : "executeQuery" ,
"query" : "INSERT INTO migration_review_queue (document_number, title, original_title, ai_suggested_category, ai_confidence, ai_issues, review_reason, status, created_at) VALUES ('{{$json.document_number}}', '{{$json.ai_result.subject || $json.title}}', '{{$json.title}}', '{{$json.ai_result.suggested_category}}', {{$json.ai_result.confidence}}, '{{JSON.stringify({ job_id: $json.job_id, temp_attachment_id: $json.temp_attachment_id, type_code: $json.ai_result.type_code, subject: $json.ai_result.subject, suggested_tags: $json.ai_result.suggested_tags, key_points: $json.ai_result.key_points || [], issued_date: $json.ai_result.issued_date, received_date: $json.ai_result.received_date })}}', '{{$json.review_reason || \"\"}}', 'PENDING', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING', review_reason = '{{$json.review_reason || \"\"}}', ai_issues = VALUES(ai_issues), created_at = NOW()" ,
"options" : { }
} ,
"id" : "c1bd4485-e58f-4270-892e-edda34c2e328" ,
"name" : "Insert Review Queue (Auto)" ,
"type" : "n8n-nodes-base.mySql" ,
"typeVersion" : 2.4 ,
"position" : [ 33856 , 13312 ] ,
"credentials" : {
"mySql" : {
"id" : "CHHfbKhMacNo03V4" ,
"name" : "MySQL account"
}
} ,
"notes" : "Auto Ready (confidence ≥ 0.85) → migration_review_queue PENDING"
} ,
{
"parameters" : {
"operation" : "executeQuery" ,
"query" : "INSERT INTO migration_review_queue (document_number, title, original_title, ai_suggested_category, ai_confidence, ai_issues, review_reason, status, created_at) VALUES ('{{$json.document_number}}', '{{$json.ai_result.subject || $json.title}}', '{{$json.title}}', '{{$json.ai_result.suggested_category}}', {{$json.ai_result.confidence}}, '{{JSON.stringify({ job_id: $json.job_id, temp_attachment_id: $json.temp_attachment_id, type_code: $json.ai_result.type_code, subject: $json.ai_result.subject, suggested_tags: $json.ai_result.suggested_tags, key_points: $json.ai_result.key_points || [], issued_date: $json.ai_result.issued_date, received_date: $json.ai_result.received_date })}}', '{{$json.review_reason}}', 'PENDING_REVIEW', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING_REVIEW', review_reason = '{{$json.review_reason}}', ai_issues = VALUES(ai_issues), created_at = NOW()" ,
"options" : { }
} ,
"id" : "f1a2b3c4-d5e6-7890-abcd-567890123456" ,
"name" : "Insert Review Queue (Flagged)" ,
"type" : "n8n-nodes-base.mySql" ,
"typeVersion" : 2.4 ,
"position" : [ 33856 , 13504 ] ,
"credentials" : {
"mySql" : {
"id" : "CHHfbKhMacNo03V4" ,
"name" : "MySQL account"
}
} ,
"notes" : "Flagged (confidence 0.60– 0.84) → migration_review_queue PENDING_REVIEW"
} ,
{
"parameters" : {
"jsCode" : "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.title),\n esc(item.json.reject_reason),\n item.json.ai_result?.confidence ?? 'N/A',\n esc(item.json.job_id || '')\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];"
} ,
"id" : "0bb3530f-02d5-44d0-ad94-c94d97d91b6a" ,
"name" : "Log Reject to CSV" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 33856 , 13696 ] ,
"notes" : "บันทึกรายการที่ถูกปฏิเสธลง CSV"
} ,
{
"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.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;"
} ,
"id" : "8250dd88-ca81-45aa-93d8-480c9bcd6b14" ,
"name" : "Log Error to CSV" ,
"type" : "n8n-nodes-base.code" ,
"typeVersion" : 2 ,
"position" : [ 33856 , 13888 ] ,
"notes" : "บันทึก Error ลง CSV"
} ,
{
"parameters" : {
"operation" : "executeQuery" ,
"query" : "INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, created_at) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', '{{$json.document_number}}', '{{$json.error_type || \"UNKNOWN\"}}', '{{$json.error || $json.parse_error}}', NOW())" ,
"options" : { }
} ,
"id" : "0f058ad0-3c09-4c9f-bdcf-503cd58ee395" ,
"name" : "Log Error to DB" ,
"type" : "n8n-nodes-base.mySql" ,
"typeVersion" : 2.4 ,
"position" : [ 34032 , 13888 ] ,
"credentials" : {
"mySql" : {
"id" : "CHHfbKhMacNo03V4" ,
"name" : "MySQL account"
}
} ,
"notes" : "บันทึก Error ลง MariaDB"
} ,
{
"parameters" : {
"operation" : "executeQuery" ,
"query" : "INSERT INTO migration_progress (batch_id, last_processed_index, status) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', {{$json.original_index || 0}}, 'RUNNING') ON DUPLICATE KEY UPDATE last_processed_index = {{$json.original_index || 0}}, updated_at = NOW()" ,
"options" : { }
} ,
"id" : "bb0e611b-db28-4266-ba40-3b5d534a16f7" ,
"name" : "Save Checkpoint" ,
"type" : "n8n-nodes-base.mySql" ,
"typeVersion" : 2.4 ,
"position" : [ 34032 , 13312 ] ,
"credentials" : {
"mySql" : {
"id" : "CHHfbKhMacNo03V4" ,
"name" : "MySQL account"
}
} ,
"notes" : "บันทึก Checkpoint ทุก record ที่ผ่าน Review Queue"
} ,
{
"parameters" : {
"amount" : "={{$('Set Configuration').first().json.config.DELAY_MS / 1000}}" ,
"unit" : "seconds"
} ,
"id" : "07c1c5d5-5ffc-4e3d-ab3e-4b62ad079388" ,
"name" : "Delay" ,
"type" : "n8n-nodes-base.wait" ,
"typeVersion" : 1 ,
"position" : [ 34208 , 13504 ] ,
"webhookId" : "38e97a99-4dcc-4b63-977a-a02945a1c369" ,
"notes" : "หน่วงเวลาระหว่าง Records"
}
] ,
"pinData" : { } ,
"connections" : {
"Form Trigger" : {
"main" : [ [ { "node" : "Set Configuration" , "type" : "main" , "index" : 0 } ] ]
} ,
"Set Configuration" : {
"main" : [ [ { "node" : "Check Backend Health" , "type" : "main" , "index" : 0 } ] ]
} ,
"Check Backend Health" : {
"main" : [ [ { "node" : "Validate Token" , "type" : "main" , "index" : 0 } ] ]
} ,
"Validate Token" : {
"main" : [ [ { "node" : "Fetch Categories" , "type" : "main" , "index" : 0 } ] ]
} ,
"Fetch Categories" : {
"main" : [ [ { "node" : "Fetch Tags" , "type" : "main" , "index" : 0 } ] ]
} ,
"Fetch Tags" : {
"main" : [ [ { "node" : "File Mount Check" , "type" : "main" , "index" : 0 } ] ]
} ,
"File Mount Check" : {
"main" : [ [ { "node" : "Read Excel Binary" , "type" : "main" , "index" : 0 } ] ]
} ,
"Read Excel Binary" : {
"main" : [ [ { "node" : "Read Excel" , "type" : "main" , "index" : 0 } ] ]
} ,
"Read Excel" : {
"main" : [ [ { "node" : "Read Checkpoint" , "type" : "main" , "index" : 0 } ] ]
} ,
"Read Checkpoint" : {
"main" : [ [ { "node" : "Process Batch + Encoding" , "type" : "main" , "index" : 0 } ] ]
} ,
"Process Batch + Encoding" : {
"main" : [ [ { "node" : "File Validator" , "type" : "main" , "index" : 0 } ] ]
} ,
"File Validator" : {
"main" : [ [ { "node" : "Read PDF File" , "type" : "main" , "index" : 0 } ] ]
} ,
"Read PDF File" : {
"main" : [ [ { "node" : "Upload PDF to Backend" , "type" : "main" , "index" : 0 } ] ]
} ,
"Upload PDF to Backend" : {
"main" : [ [ { "node" : "Build AI Job Payload" , "type" : "main" , "index" : 0 } ] ]
} ,
"Build AI Job Payload" : {
"main" : [ [ { "node" : "Submit AI Job" , "type" : "main" , "index" : 0 } ] ]
} ,
"Submit AI Job" : {
"main" : [ [ { "node" : "Poll AI Job Status" , "type" : "main" , "index" : 0 } ] ]
} ,
"Poll AI Job Status" : {
"main" : [ [ { "node" : "Parse & Validate AI Response" , "type" : "main" , "index" : 0 } ] ]
} ,
"Parse & Validate AI Response" : {
"main" : [ [ { "node" : "Route by Confidence" , "type" : "main" , "index" : 0 } ] ]
} ,
"Route by Confidence" : {
"main" : [
[ { "node" : "Insert Review Queue (Auto)" , "type" : "main" , "index" : 0 } ] ,
[ { "node" : "Insert Review Queue (Flagged)" , "type" : "main" , "index" : 0 } ] ,
[ { "node" : "Log Reject to CSV" , "type" : "main" , "index" : 0 } ] ,
[ { "node" : "Log Error to CSV" , "type" : "main" , "index" : 0 } ]
]
} ,
"Insert Review Queue (Auto)" : {
"main" : [ [ { "node" : "Save Checkpoint" , "type" : "main" , "index" : 0 } ] ]
} ,
"Insert Review Queue (Flagged)" : {
"main" : [ [ { "node" : "Save Checkpoint" , "type" : "main" , "index" : 0 } ] ]
} ,
"Save Checkpoint" : {
"main" : [ [ { "node" : "Delay" , "type" : "main" , "index" : 0 } ] ]
} ,
"Log Reject to CSV" : {
"main" : [ [ { "node" : "Delay" , "type" : "main" , "index" : 0 } ] ]
} ,
"Log Error to CSV" : {
"main" : [ [ { "node" : "Log Error to DB" , "type" : "main" , "index" : 0 } ] ]
} ,
"Log Error to DB" : {
"main" : [ [ { "node" : "Delay" , "type" : "main" , "index" : 0 } ] ]
} ,
"Delay" : {
"main" : [ [ { "node" : "Read Checkpoint" , "type" : "main" , "index" : 0 } ] ]
}
} ,
"active" : false ,
"settings" : {
"executionOrder" : "v1" ,
"availableInMCP" : false
} ,
"versionId" : "v2.0.0-adr023a-compliant" ,
"meta" : {
"templateCredsSetupCompleted" : true ,
"instanceId" : "9e70e47c1eaf3bac72f497ddfbde0983f840f7d0f059537f7e37dd70de18ecb7"
} ,
"id" : "u7CLP05AyFb8Um0P" ,
"tags" : [
{ "name" : "migration" , "createdAt" : "2026-05-23" } ,
{ "name" : "v2" , "createdAt" : "2026-05-23" }
]
}