{ "name": "LCBP3 Migration Workflow v3.0.0", "nodes": [ { "parameters": { "formTitle": "LCBP3 Migration v3 - ตั้งค่าก่อนรัน", "formDescription": "กรุณาตั้งค่า Batch, Project/Contract Scope และ Excel file ก่อนรัน Migration (ADR-030)", "formFields": { "values": [ { "fieldLabel": "Batch Size", "fieldType": "number", "placeholder": "10" }, { "fieldLabel": "Excel File Path", "placeholder": "/home/node/.n8n-files/staging_ai/C22024.xlsx" }, { "fieldLabel": "Migration Token", "placeholder": "Bearer " }, { "fieldLabel": "Project Public ID (UUID)", "placeholder": "เว้นว่าง = ใช้ค่า prompt template/default" }, { "fieldLabel": "Contract Public ID (UUID)", "placeholder": "เว้นว่าง = ไม่กรองตามสัญญา" } ] }, "options": {} }, "id": "8cf0c7a9-7166-463f-8b8b-b622bf46c605", "name": "Form Trigger", "type": "n8n-nodes-base.formTrigger", "typeVersion": 2.2, "position": [ -11376, 6272 ], "webhookId": "3698859a-0217-4675-ae92-eb67e4242236", "notes": "v3: เพิ่ม Project/Contract public UUID เพื่อ filter Master Data Context (ADR-030)" }, { "parameters": { "jsCode": "// ============================================\n// CONFIGURATION v3.0 — ADR-030 Context-Aware Prompts\n// n8n เรียกแค่ DMS Backend API เท่านั้น — ห้ามเรียก AI runtime โดยตรง (ADR-023A)\n// v3 เพิ่ม: PROJECT_PUBLIC_ID + CONTRACT_PUBLIC_ID สำหรับ Master Data Context filter\n// ============================================\nconst formData = $('Form Trigger').first()?.json || {};\nconst batchSizeInput = parseInt(String(formData['Batch Size'] || '0'));\nconst excelFileInput = String(formData['Excel File Path'] || '').trim();\nconst jwtTokenInput = String(formData['Migration Token'] || '').trim();\nconst resolvedExcelFile = excelFileInput || '/home/node/.n8n-files/staging_ai/C22024.xlsx';\n\n// v3: อ่าน Project/Contract public UUID สำหรับ context filter (ADR-030 + ADR-019)\nconst projectPublicIdInput = String(formData['Project Public ID (UUID)'] || '').trim();\nconst contractPublicIdInput = String(formData['Contract Public ID (UUID)'] || '').trim();\n\n// Validate Excel file path to prevent path traversal\nif (resolvedExcelFile.includes('..') || resolvedExcelFile.includes('~')) {\n throw new Error('Invalid Excel file path: path traversal detected');\n}\n\nif (!jwtTokenInput || !jwtTokenInput.startsWith('Bearer ')) {\n throw new Error('JWT Token is required and must start with \"Bearer \"');\n}\n\nconst BATCH_ID = (() => {\n // ใช้ Excel filename เป็น BATCH_ID เพื่อให้ checkpoint ทำงานถูกต้อง\n // เปลี่ยน Excel file = BATCH_ID ใหม่ = เริ่มใหม่ (ปลอดภัย)\n // ใช้ Excel file เดิม = BATCH_ID เดิม = resume ได้\n const filename = resolvedExcelFile.split('/').pop().split('\\\\').pop();\n const baseName = filename.replace(/\\.xlsx?$/i, '');\n return baseName + '-MIGRATION';\n})();\n\nconst CONFIG = {\n // Backend Settings\n BACKEND_URL: 'http://backend:3000',\n MIGRATION_TOKEN: jwtTokenInput,\n\n // Batch Settings\n BATCH_SIZE: batchSizeInput > 0 ? batchSizeInput : 10,\n BATCH_ID: BATCH_ID,\n DELAY_MS: 60000,\n\n // AI Job Settings (ADR-023A)\n AI_JOB_POLL_INTERVAL_MS: 5000,\n AI_JOB_TIMEOUT_MS: 120000,\n\n // File Settings\n EXCEL_FILE: resolvedExcelFile,\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/pdfs',\n LOG_PATH: '/home/node/.n8n-files/staging_ai/logs',\n\n // v3: Context Filter สำหรับ Master Data (ADR-030)\n // ใช้ public UUID เท่านั้นตาม ADR-019 — backend resolve เป็น internal id ภายใน service\n PROJECT_PUBLIC_ID: projectPublicIdInput || null,\n CONTRACT_PUBLIC_ID: contractPublicIdInput || null,\n};\n\nreturn [{ json: { config: CONFIG } }];" }, "id": "c785a235-4992-4396-884d-4c2137fb2304", "name": "Set Configuration", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -11184, 6272 ], "notes": "v3: เพิ่ม PROJECT_PUBLIC_ID + CONTRACT_PUBLIC_ID และรับ token จาก Form Trigger (ADR-019/030)" }, { "parameters": { "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/health", "options": { "timeout": 5000 } }, "id": "0c23585c-33e3-4eaa-bc54-3cff5079745f", "name": "Check Backend Health", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -11008, 6272 ], "notes": "ตรวจสอบ Backend พร้อมใช้งาน" }, { "parameters": { "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/auth/profile", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" } ] }, "options": { "timeout": 5000 } }, "id": "69db8e96-2203-47fc-b8a8-c6573c53178e", "name": "Validate Token", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -10832, 6272 ], "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": "c9e3959b-4019-4ec6-b254-4a89156ca0d0", "name": "Fetch Categories", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -11184, 6464 ], "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": "e3ed851e-1ef2-42d4-84bd-df29cfe6e13e", "name": "Fetch Tags", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -11008, 6464 ], "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.publicId, tagName: t.tag_name || t.name || '' })).filter(t => t.tagName && t.publicId)\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": "6568ed41-da80-4f00-9378-905cc95d86f0", "name": "File Mount Check", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -10832, 6464 ], "notes": "ตรวจสอบ File System + รวบรวม master data สำหรับ AI payload" }, { "parameters": { "fileSelector": "={{ $json.excel_target }}", "options": {} }, "id": "1802f33d-7fc6-42cb-897b-e312dc7c9626", "name": "Read Excel Binary", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [ -11184, 6656 ], "notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ" }, { "parameters": { "options": {} }, "id": "af6a69f5-7f04-40f6-9608-1cfe231ee85f", "name": "Read Excel", "type": "n8n-nodes-base.spreadsheetFile", "typeVersion": 2, "position": [ -11008, 6656 ], "notes": "แปลงข้อมูล Excel เป็น JSON Data" }, { "parameters": { "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL + '/api/ai/migration/checkpoint/' + $('Set Configuration').first().json.config.BATCH_ID }}", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" } ] }, "options": { "timeout": 10000 } }, "id": "ebddd4ee-0f4e-471b-9f01-179dbcb7f0ba", "name": "Read Checkpoint", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -10832, 6672 ], "alwaysOutputData": true, "notes": "GET /api/ai/migration/checkpoint/:batchId — ADR-023A (ไม่ใช้ MySQL โดยตรง)" }, { "parameters": { "jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.data?.lastProcessedIndex ?? cpJson.lastProcessedIndex ?? cpJson.data?.last_processed_index ?? 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 }\n };\n});" }, "id": "b488f4ac-4864-4a16-ac26-71bd850fa49b", "name": "Process Batch + Encoding", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -11184, 6880 ], "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": "e831cd28-880c-4f93-99fc-901383d1cda3", "name": "File Validator", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -10640, 6272 ], "notes": "ตรวจสอบไฟล์ PDF ใน Directory" }, { "parameters": { "fileSelector": "={{ $json.file_path }}", "options": {} }, "id": "20625b33-17e8-4730-94f1-37c9b6c24df9", "name": "Read PDF File", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [ -10640, 6464 ], "onError": "continueErrorOutput", "notes": "อ่าน PDF Binary เพื่อเตรียม Upload" }, { "parameters": { "method": "POST", "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/files/upload", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" }, { "name": "Idempotency-Key", "value": "={{ $json.batch_id + ':' + $json.document_number + ':upload' }}" } ] }, "sendBody": true, "contentType": "multipart-form-data", "bodyParameters": { "parameters": [ { "parameterType": "formBinaryData", "name": "file", "inputDataFieldName": "data" } ] }, "options": { "timeout": 300000 } }, "id": "2677c8b3-223a-4952-b4e2-41b92bd7f059", "name": "Upload PDF to Backend", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -10416, 6272 ], "onError": "continueErrorOutput", "notes": "ADR-023A: อัพโหลด PDF เข้า Backend Temp Storage → รับ temp_attachment_id" }, { "parameters": { "jsCode": "// v3: Build AI Job Payload พร้อม contextOverride สำหรับ Master Data filter (ADR-030)\nconst 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\n// Backend returns { data: { publicId, tempId, ... } } per ADR-019 (id is @Exclude'd)\nconst tempAttachmentPublicId = uploadResponse?.data?.publicId || uploadResponse?.publicId;\nif (!tempAttachmentPublicId) {\n throw new Error(`Upload failed — no temp_attachment_public_id returned. Upload response: ${JSON.stringify(uploadResponse)}`);\n}\n\n// Validate required fields per DTO\nconst docNumber = String(metaItem.document_number || '').trim();\nconst docTitle = String(metaItem.title || '').trim();\nif (!docNumber) {\n throw new Error(`document_number is empty for item: ${JSON.stringify(metaItem)}`);\n}\nif (!docTitle) {\n throw new Error(`title is empty for document: ${docNumber}`);\n}\n\n// Normalize existingTags to match TagOptionDto (tagName is required)\nconst existingTags = (mountCheckData.existing_tags || [])\n .filter(t => t.tagName && t.tagName.trim())\n .map(t => ({\n publicId: t.publicId || undefined,\n tagName: String(t.tagName).trim(),\n colorCode: t.colorCode || undefined,\n }));\n\n// v3: contextOverride — ส่ง public UUID ให้ backend resolve master data context\n// ตาม ADR-030: filter master data ตาม project/contract scope เพื่อลด context size\n// null = ไม่ filter (ใช้ค่าจาก context_config ของ active prompt template)\nconst contextOverride = {\n projectPublicId: config.PROJECT_PUBLIC_ID || null,\n contractPublicId: config.CONTRACT_PUBLIC_ID || null,\n};\n\n// สร้าง SubmitAiJobDto ตาม Backend DTO (v3)\nconst submitPayload = {\n type: 'migrate-document',\n payload: {\n tempAttachmentId: String(tempAttachmentPublicId),\n documentNumber: docNumber,\n title: docTitle,\n batchId: String(config.BATCH_ID),\n existingTags: existingTags,\n systemCategories: mountCheckData.system_categories || [],\n // v3 NEW: contextOverride สำหรับ ADR-030 Master Data Context Filter\n contextOverride: contextOverride,\n }\n};\n\n// Idempotency-Key: deterministic format ตาม FR-001a\nconst idempotencyKey = `${config.BATCH_ID}:${docNumber}`;\n\nreturn [{\n json: {\n ...metaItem,\n temp_attachment_public_id: tempAttachmentPublicId,\n submit_payload: submitPayload,\n idempotency_key: idempotencyKey,\n }\n}];" }, "id": "fad3f363-a595-4a43-a48f-cefb258c2687", "name": "Build AI Job Payload", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -10400, 6496 ], "notes": "v3: เพิ่ม contextOverride (projectPublicId, contractPublicId) ตาม ADR-019/030 — 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.stringify($json.submit_payload) }}", "options": { "timeout": 30000 } }, "id": "7fb24018-9739-4ae9-8650-d118d73e38d7", "name": "Submit AI Job", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -10400, 6672 ], "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 // ยังไม่เสร็จ → รอแล้ว poll ใหม่\n await new Promise(resolve => setTimeout(resolve, pollIntervalMs));\n}" }, "id": "2abd110c-292e-46e2-9033-18d38963161d", "name": "Poll AI Job Status", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -10208, 6272 ], "notes": "Poll GET /api/ai/jobs/{jobId} ทุก 5 วินาที (timeout 120 วินาที)" }, { "parameters": { "jsCode": "// v3: Parse & Validate AI Response — รองรับ 11-field schema (ADR-030)\n// recipients เป็น Array<{ organizationPublicId, recipientType }> แทน parallel arrays\nconst config = $('Set Configuration').first().json.config;\nconst items = $input.all();\nconst results = [];\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 // ================================================================\n // v3: 11-field schema validation (ADR-030)\n // ================================================================\n \n // 1. projectPublicId — UUID string หรือ null\n const projectPublicId = typeof ai.projectPublicId === 'string' && ai.projectPublicId.trim()\n ? ai.projectPublicId.trim()\n : null;\n \n // 2. correspondenceTypeCode — รหัสประเภท เช่น RFA, LETTER, TRANSMITTAL\n const correspondenceTypeCode = typeof ai.correspondenceTypeCode === 'string' && ai.correspondenceTypeCode.trim()\n ? ai.correspondenceTypeCode.trim().toUpperCase()\n : null;\n \n // 3. disciplineCode — รหัสสาขางาน เช่น GEN, STR\n const disciplineCode = typeof ai.disciplineCode === 'string' && ai.disciplineCode.trim()\n ? ai.disciplineCode.trim().toUpperCase()\n : null;\n \n // 4. originatorOrganizationPublicId — UUID string หรือ null\n const originatorOrganizationPublicId = typeof ai.originatorOrganizationPublicId === 'string' && ai.originatorOrganizationPublicId.trim()\n ? ai.originatorOrganizationPublicId.trim()\n : null;\n \n // 5. recipients — Object Array ตาม spec ADR-030\n // รูปแบบ: Array<{ organizationPublicId: string, recipientType: 'TO' | 'CC' }>\n // ห้ามใช้ parallel arrays (recipientOrganizationPublicIds + recipientTypes)\n let recipients = [];\n if (Array.isArray(ai.recipients)) {\n recipients = ai.recipients\n .filter(r => r && typeof r === 'object' && typeof r.organizationPublicId === 'string' && r.organizationPublicId.trim())\n .map(r => ({\n organizationPublicId: String(r.organizationPublicId).trim(),\n recipientType: ['TO', 'CC'].includes(String(r.recipientType || '').trim().toUpperCase())\n ? String(r.recipientType).trim().toUpperCase()\n : 'TO',\n }));\n }\n \n // 6. subject — หัวข้อเอกสาร\n const subject = typeof ai.subject === 'string' && ai.subject.trim()\n ? ai.subject.trim()\n : data.title || '';\n \n // 7. documentDate — YYYY-MM-DD\n const documentDate = typeof ai.documentDate === 'string' && /^\\d{4}-\\d{2}-\\d{2}$/.test(ai.documentDate.trim())\n ? ai.documentDate.trim()\n : null;\n \n // 8. tags — string[]\n const suggestedTags = Array.isArray(ai.tags)\n ? ai.tags.map(t => {\n if (typeof t === 'string') return { tagName: t.trim(), isNew: true, colorCode: 'default' };\n if (typeof t === 'object' && t.tagName) return {\n tagName: String(t.tagName || t.tag_name || '').trim(),\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 };\n return null;\n }).filter(t => t && t.tagName)\n : [];\n \n // 9. summary — ภาษาไทย 4-5 ประโยค\n const summary = typeof ai.summary === 'string' && ai.summary.trim() ? ai.summary.trim() : null;\n \n // 10. confidence — float 0-1\n const confidence = typeof ai.confidence === 'number' ? Math.max(0, Math.min(1, ai.confidence)) : 0;\n \n // ================================================================\n // Confidence-based routing (เหมือน v2)\n // route_index: 0=Auto Ready, 1=Review Flagged, 2=Rejected, 3=Error Log\n // ================================================================\n let route_index;\n let review_reason = '';\n let reject_reason = '';\n \n // ตรวจสอบ UUID fields ที่จำเป็น — ถ้าไม่มี → Flagged เพื่อ human validation\n const missingUuids = [];\n if (!projectPublicId) missingUuids.push('projectPublicId');\n if (!originatorOrganizationPublicId) missingUuids.push('originatorOrganizationPublicId');\n if (recipients.length === 0) missingUuids.push('recipients');\n \n if (missingUuids.length > 0) {\n route_index = 1;\n review_reason = `UUID fields ไม่ครบ: ${missingUuids.join(', ')} — ต้องการ human validation (ADR-030)`;\n } else if (confidence >= 0.85) {\n route_index = 0; // Auto Ready\n } else if (confidence >= 0.50) {\n route_index = 1; // Review Flagged\n review_reason = `confidence ต่ำ (${confidence.toFixed(2)}) — ต้องการ human review`;\n } else if (confidence > 0) {\n route_index = 2; // Rejected\n reject_reason = `confidence ต่ำเกิน (${confidence.toFixed(2)}) — ต่ำกว่า threshold 0.50`;\n } else {\n route_index = 3; // Error\n }\n \n const normalizedAi = {\n // v3: 11-field schema (ADR-030)\n projectPublicId,\n correspondenceTypeCode,\n disciplineCode,\n originatorOrganizationPublicId,\n recipients,\n subject,\n documentDate,\n tags: suggestedTags,\n summary,\n confidence,\n };\n \n results.push({\n json: {\n ...data,\n ai_result: normalizedAi,\n review_reason,\n reject_reason,\n route_index,\n }\n });\n}\n\nreturn results;" }, "id": "419d8d1a-06c7-4cca-b1ed-53cf0a632c7e", "name": "Parse & Validate AI Response", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -10208, 6496 ], "notes": "v3: 11-field schema (ADR-030) — recipients เป็น Object Array + UUID validation routing" }, { "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": "5abd2557-cb16-4102-aca3-db8e8d009286", "name": "Route by Confidence", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ -10048, 6560 ] }, { "parameters": { "method": "POST", "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/queue/record", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" }, { "name": "Idempotency-Key", "value": "={{ $json.idempotency_key + ':queue' }}" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ batchId: $json.batch_id, documentNumber: $json.document_number, subject: ($json.ai_result?.subject || $json.title), originalSubject: $json.title, tempAttachmentId: $json.temp_attachment_public_id === undefined ? undefined : $json.temp_attachment_public_id, confidence: $json.ai_result?.confidence, reviewReason: $json.review_reason || '', status: 'PENDING', aiResult: $json.ai_result, idempotencyKey: $json.batch_id + ':' + $json.document_number }) }}", "options": { "timeout": 10000 } }, "id": "48a0cc23-8219-45bc-b68b-4bf37ca68364", "name": "Insert Review Queue (Auto)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -9888, 6272 ], "onError": "continueErrorOutput", "notes": "POST /api/ai/migration/queue/record (PENDING) — ADR-023A" }, { "parameters": { "method": "POST", "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/queue/record", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" }, { "name": "Idempotency-Key", "value": "={{ $json.idempotency_key + ':queue' }}" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ batchId: $json.batch_id, documentNumber: $json.document_number, subject: ($json.ai_result?.subject || $json.title), originalSubject: $json.title, tempAttachmentId: $json.temp_attachment_public_id === undefined ? undefined : $json.temp_attachment_public_id, confidence: $json.ai_result?.confidence, reviewReason: $json.review_reason || '', status: 'PENDING_REVIEW', aiResult: $json.ai_result, idempotencyKey: $json.batch_id + ':' + $json.document_number }) }}", "options": { "timeout": 10000 } }, "id": "2fc51220-e960-4ba5-a8ba-72dc7d786359", "name": "Insert Review Queue (Flagged)", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -9808, 6576 ], "onError": "continueErrorOutput", "notes": "POST /api/ai/migration/queue/record (PENDING_REVIEW) — ADR-023A" }, { "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_${config.BATCH_ID}.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": "62c48542-9b36-4db3-83ef-23bf1af39875", "name": "Log Reject to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -9808, 6784 ], "notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV" }, { "parameters": { "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": "724da26e-5c71-4146-8a4c-a16299dfae17", "name": "Log Error to CSV", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -10416, 6864 ], "notes": "บันทึก Error ลง CSV" }, { "parameters": { "method": "POST", "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/errors", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" }, { "name": "Idempotency-Key", "value": "={{ ($json.batch_id || $('Set Configuration').first().json.config.BATCH_ID) + ':' + ($json.document_number || 'WORKFLOW') + ':error' }}" } ] }, "sendBody": true, "specifyBody": "json", "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 } }, "id": "aa49b719-9133-4ab8-af24-27754c4b4cd2", "name": "Log Error to DB", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -10208, 6864 ], "onError": "continueErrorOutput", "notes": "POST /api/ai/migration/errors — ADR-023A" }, { "parameters": { "method": "POST", "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/checkpoint", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" }, { "name": "Idempotency-Key", "value": "={{ $('Set Configuration').first().json.config.BATCH_ID + ':checkpoint:' + (($json.original_index || 0) + 1) }}" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, lastProcessedIndex: ($json.original_index || 0) + 1, status: 'RUNNING' }) }}", "options": { "timeout": 10000 } }, "id": "702e0d86-786c-4731-89bb-a7b80aa4d425", "name": "Save Checkpoint", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -9664, 6384 ], "onError": "continueErrorOutput", "notes": "POST /api/ai/migration/checkpoint — ADR-023A" }, { "parameters": { "amount": "={{$('Set Configuration').first().json.config.DELAY_MS / 1000}}", "unit": "seconds" }, "id": "f8eadb50-d241-423c-a62e-4c87fd96c3be", "name": "Delay", "type": "n8n-nodes-base.wait", "typeVersion": 1, "position": [ -9536, 6880 ], "webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369", "notes": "หน่วงเวลาระหว่าง Records" }, { "parameters": { "rules": { "values": [ { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.batch_complete }}", "rightValue": true, "operator": { "type": "boolean", "operation": "equals", "singleValue": true } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "Batch Complete" }, { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.batch_complete }}", "rightValue": false, "operator": { "type": "boolean", "operation": "equals", "singleValue": true } } ], "combinator": "and" }, "renameOutput": true, "outputKey": "Continue Loop" } ] }, "options": {} }, "id": "0ef559e7-8ffc-4bf5-b6ef-2d5e2d44d57a", "name": "Check Batch Complete", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [ -11008, 6880 ] }, { "parameters": { "method": "POST", "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/checkpoint", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Authorization", "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" }, { "name": "Idempotency-Key", "value": "={{ $('Set Configuration').first().json.config.BATCH_ID + ':complete' }}" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, lastProcessedIndex: $json.total_processed, status: 'COMPLETED' }) }}", "options": { "timeout": 10000 } }, "id": "c059f49b-a184-43ff-aef4-a84077c10524", "name": "Mark Batch Complete", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.1, "position": [ -10784, 6864 ], "notes": "Update checkpoint status to COMPLETED when batch finishes" } ], "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": "Check Batch Complete", "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 } ], [ { "node": "Log Error to CSV", "type": "main", "index": 0 } ] ] }, "Upload PDF to Backend": { "main": [ [ { "node": "Build AI Job Payload", "type": "main", "index": 0 } ], [ { "node": "Log Error to CSV", "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 } ], [ { "node": "Log Error to CSV", "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 } ], [ { "node": "Log Error to CSV", "type": "main", "index": 0 } ] ] }, "Insert Review Queue (Flagged)": { "main": [ [ { "node": "Save Checkpoint", "type": "main", "index": 0 } ], [ { "node": "Log Error to CSV", "type": "main", "index": 0 } ] ] }, "Save Checkpoint": { "main": [ [ { "node": "Delay", "type": "main", "index": 0 } ], [ { "node": "Log Error to CSV", "type": "main", "index": 0 } ] ] }, "Delay": { "main": [ [ { "node": "Read Checkpoint", "type": "main", "index": 0 } ] ] }, "Check Batch Complete": { "main": [ [ { "node": "Mark Batch Complete", "type": "main", "index": 0 } ], [ { "node": "File Validator", "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 } ], [ { "node": "Delay", "type": "main", "index": 0 } ] ] } }, "active": false, "settings": { "executionOrder": "v1", "binaryMode": "separate" }, "versionId": "adr030-v3-context-aware-prompts", "meta": { "templateCredsSetupCompleted": true, "instanceId": "9e70e47c1eaf3bac72f497ddfbde0983f840f7d0f059537f7e37dd70de18ecb7" }, "id": "4LlPbAKU5BZLgiTg", "tags": [ { "updatedAt": "2026-05-27T00:00:00.000Z", "createdAt": "2026-05-27T00:00:00.000Z", "id": "mGZTyPfxbcsAuFpR", "name": "migration" }, { "updatedAt": "2026-05-27T00:00:00.000Z", "createdAt": "2026-05-27T00:00:00.000Z", "id": "v3TagId001", "name": "v3" }, { "updatedAt": "2026-05-27T00:00:00.000Z", "createdAt": "2026-05-27T00:00:00.000Z", "id": "adr030TagId001", "name": "adr-030" } ] }