Files
lcbp3/specs/03-Data-and-Storage/n8n.workflow.v2.json
T
admin ff5cadc9f2
CI / CD Pipeline / build (push) Successful in 4m55s
CI / CD Pipeline / deploy (push) Successful in 5m49s
690523:1623 ADR-028-228 #05
2026-05-23 16:23:12 +07:00

583 lines
35 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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.600.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" }
]
}