{
  "name": "LCBP3 Migration Workflow v1.8.0",
  "nodes": [
    {
      "parameters": {
        "formTitle": "LCBP3 Migration - เลือก Model",
        "formDescription": "กรุณาเลือก Ollama Model และตั้งค่าก่อนรัน",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Ollama Model (Primary)",
              "fieldType": "dropdown",
              "fieldOptions": {
                "values": [
                  {
                    "option": "qwen2.5:7b-instruct-q4_K_M (สมดุล - แนะนำ)"
                  },
                  {
                    "option": "scb10x/typhoon2.1-gemma3-4b (เร็ว + ไทยดี)"
                  },
                  {
                    "option": "promptnow/openthaigpt1.5-7b-instruct-q4_k_m (ไทยเฉพาะทาง)"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldLabel": "Batch Size",
              "fieldType": "number",
              "placeholder": "2"
            },
            {
              "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 เพื่อเลือก Model ก่อนรัน"
    },
    {
      "parameters": {
        "jsCode": "// Read model selected from Form Trigger dropdown\nconst formData = $('Form Trigger').first()?.json || {};\nconst selectedModelLabel = String(formData['Ollama Model (Primary)'] || '');\n\n// Extract just the model ID (before the space in the label)\nconst MODEL_MAP = {\n  'qwen2.5:7b-instruct-q4_K_M (สมดุล - แนะนำ)': 'qwen2.5:7b-instruct-q4_K_M',\n  'scb10x/typhoon2.1-gemma3-4b (เร็ว + ไทยดี)': 'scb10x/typhoon2.1-gemma3-4b',\n  'promptnow/openthaigpt1.5-7b-instruct-q4_k_m (ไทยเฉพาะทาง)': 'promptnow/openthaigpt1.5-7b-instruct-q4_k_m'\n};\nconst selectedModel = MODEL_MAP[selectedModelLabel] || 'qwen2.5:7b-instruct-q4_K_M';\n\nconst batchSizeInput = parseInt(formData['Batch Size'] || '0');\nconst excelFileInput = String(formData['Excel File Path'] || '').trim();\n\nconst CONFIG = {\n  // Ollama Settings\n  OLLAMA_HOST: 'http://192.168.20.100:11434',\n  // Model selected from Form UI\n  OLLAMA_MODEL_PRIMARY: selectedModel,\n  // Fallback\n  OLLAMA_MODEL_FALLBACK: 'mistral:7b-instruct-q4_K_M',\n  \n  // Backend Settings\n  BACKEND_URL: 'https://backend.np-dms.work',\n  MIGRATION_TOKEN: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pZ3JhdGlvbl9ib3QiLCJzdWIiOjUsInNjb3BlIjoiR2xvYmFsIiwiaWF0IjoxNzcyNzc0MzI5LCJleHAiOjQ5Mjg1MzQzMjl9.TtA8zoHy7G9J5jPgYQPv7yw-9X--B_hl-Nv-c9V4PaA',\n  \n  // Batch Settings\n  BATCH_SIZE: batchSizeInput > 0 ? batchSizeInput : 2,\n  BATCH_ID: (() => { const d = new Date(Date.now() + 7 * 3600000); const s = d.toISOString(); return s.substring(0,10).replace(/-/g,'') + ':' + s.substring(11,16).replace(/:/g,''); })(),\n  DELAY_MS: 2000,\n  \n  // Thresholds\n  CONFIDENCE_HIGH: 0.85,\n  CONFIDENCE_LOW: 0.60,\n  MAX_RETRY: 3,\n  FALLBACK_THRESHOLD: 5,\n  \n  // Source Definitions - แก้ไขโฟลเดอร์และไฟล์ทำงานที่นี่\n  EXCEL_FILE: excelFileInput || '/home/node/.n8n-files/staging_ai/C22024.xlsx',\n  SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/Incoming/08C.2/2567',\n  LOG_PATH: '/home/node/.n8n-files/migration_logs',\n  \n  // Database\n  DB_HOST: '192.168.10.8',\n  DB_PORT: 3306,\n  DB_NAME: 'lcbp3',\n  DB_USER: 'migration_bot',\n  DB_PASSWORD: 'Center2025',\n  // Project ID: 1=LCBP3, 2=LCBP3-C1, 3=LCBP3-C2\n  PROJECT_ID: 3\n};\n\nreturn [{ json: { config_loaded: true, timestamp: new Date().toISOString(), config: CONFIG } }];"
      },
      "id": "8f1d3378-cca6-48b6-99db-693e46ac81ef",
      "name": "Set Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [31216, 13504],
      "notes": "กำหนดค่า Configuration ทั้งหมด - แก้ไขที่นี่ก่อนรัน"
    },
    {
      "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": [31216, 13696],
      "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": [31392, 13696],
      "notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend"
    },
    {
      "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": {
        "jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\n// Check file mount and inputs\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 out of the previous node (Fetch Categories) if available\n  // otherwise use fallback array\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); // very loose mapping depending on API response\n    }\n  } catch(e) {}\n  \n  // Grab existing tags from Fetch Tags node\n  let existingTags = [];\n  try {\n    const tagData = $('Fetch Tags').first()?.json?.data || [];\n    existingTags = Array.isArray(tagData) ? tagData.map(t => t.tag_name || t.name || '').filter(Boolean) : [];\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": [31216, 13904],
      "notes": "ตรวจสอบ File System มีไฟล์ Excel และ Folder ตามตั้งค่า"
    },
    {
      "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": [31632, 13744],
      "alwaysOutputData": true,
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "onError": "continueErrorOutput",
      "notes": "อ่านตำแหน่งล่าสุดที่ประมวลผล"
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.excel_target }}",
        "options": {}
      },
      "id": "063bcef1-791a-4923-a659-8b9a0ba3e336",
      "name": "Read Excel Binary",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [31392, 13904],
      "notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ"
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "e07efdde-b9b1-402a-ba01-44175982749b",
      "name": "Read Excel",
      "type": "n8n-nodes-base.spreadsheetFile",
      "typeVersion": 2,
      "position": [31392, 14112],
      "notes": "แปลงข้อมูล Excel เป็น JSON Data"
    },
    {
      "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// Encoding Normalization\nconst normalize = (str) => {\n  if (!str) return '';\n  return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n  // Safe getter to handle whitespace or case in Excel column names\n  const getVal = (possibleKeys) => {\n    const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n    if (exactMatch) return item[exactMatch];\n    \n    // Fallback: Check lowercase/trimmed keys if exact match fails\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      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', 'document_date', 'Document_Date'])),\n      received_date: normalize(getVal(['received_date', 'Received_date', 'Received Date', 'receive', 'Receive'])),\n      correspondence_type: normalize(getVal(['correspondence_types', 'correspondence_type', 'Correspondence Types', 'Correspondence Type'])),\n      sender: normalize(getVal(['sender', 'Sender', 'from', 'From'])),\n      receiver: normalize(getVal(['receiver', 'Receiver', 'to', 'To'])),\n      project_code: normalize(getVal(['project_code', 'Project Code', 'project', 'Project'])),\n      _raw_excel_keys: Object.keys(item),\n      _raw_excel: item\n    }\n  };\n});"
      },
      "id": "80845e32-c283-4e9f-af73-6339d675fb38",
      "name": "Process Batch + Encoding",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [31808, 13488],
      "alwaysOutputData": true,
      "notes": "ตัด Batch + Normalize UTF-8"
    },
    {
      "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\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n  const fileName = item.json?.file_name;\n  if (!fileName) {\n    errors.push({\n      ...item,\n      json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME', file_exists: false }\n    });\n    continue;\n  }\n  \n  // Use file name from Excel directly, add .pdf if missing\n  let safeName = path.basename(String(fileName)).normalize('NFC');\n  if (!safeName.toLowerCase().endsWith('.pdf')) {\n    safeName += '.pdf';\n  }\n  const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n  \n  // Path traversal check\n  if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n    errors.push({\n      ...item,\n      json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY', file_exists: false }\n    });\n    continue;\n  }\n  \n  try {\n    if (fs.existsSync(filePath)) {\n      const stats = fs.statSync(filePath);\n      validated.push({\n        ...item,\n        json: { ...item.json, file_valid: true, file_exists: true, file_size: stats.size, file_path: filePath }\n      });\n    } else {\n      errors.push({\n        ...item,\n        json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND', file_exists: false }\n      });\n    }\n  } catch (err) {\n    errors.push({\n      ...item,\n      json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR', file_exists: false }\n    });\n  }\n}\n\nreturn [...validated, ...errors];"
      },
      "id": "2183d687-4708-4d77-a0a9-13ccf29baf69",
      "name": "File Validator",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [31984, 13488],
      "notes": "ตรวจสอบไฟล์ PDF ตัวชี้ใน Directory จาก Config"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT is_fallback_active, recent_error_count FROM migration_fallback_state WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1",
        "options": {}
      },
      "id": "8b0c61d0-96e4-468a-991f-a40e534e167a",
      "name": "Check Fallback State",
      "type": "n8n-nodes-base.mySql",
      "typeVersion": 2.4,
      "position": [31792, 13888],
      "alwaysOutputData": true,
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "onError": "continueErrorOutput",
      "notes": "ตรวจสอบว่าต้องใช้ Fallback Model หรือไม่"
    },
    {
      "parameters": {
        "jsCode": "const config = $('Set Configuration').first().json.config;\nconst fallbackState = $('Check Fallback State').first()?.json || { is_fallback_active: false, recent_error_count: 0 };\nconst isFallback = fallbackState.is_fallback_active || false;\nconst model = isFallback ? config.OLLAMA_MODEL_FALLBACK : config.OLLAMA_MODEL_PRIMARY;\n\n// Read DB Context\nconst dbContext = $('Fetch DB Context').all().map(i => i.json);\nconst dbProjects = dbContext.filter(d => d.type === 'projects').map(d => ({id: d.id, code: d.text1, name: d.text2}));\nconst dbDisciplines = dbContext.filter(d => d.type === 'disciplines').map(d => ({id: d.id, th: d.text1, en: d.text2}));\nconst dbOrgs = dbContext.filter(d => d.type === 'organizations').map(d => ({id: d.id, name: d.text1, code: d.text2}));\nconst dbTags = dbContext.filter(d => d.type === 'tags').map(d => ({id: d.id, name: d.text1}));\nconst dbCorrTypes = dbContext.filter(d => d.type === 'correspondence_types').map(d => ({id: d.id, code: d.text1, name: d.text2}));\n\nlet systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\ntry { systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; } catch (e) {}\n\nconst pdfItems = $('Extract PDF Text').all();\n// File Validator passes all original Excel JSON fields through (sender, receiver, project_code, etc.)\n// Read PDF File overwrites the JSON with binary data, so we must go back one step\nconst metaItems = $('File Validator').all();\n\nreturn pdfItems.map((pdfItem, i) => {\n  const item = metaItems[i] || pdfItem;\n\n  const docNum = String(item.json.document_number || '');\n  const title = String(item.json.title || '');\n  const legacyNum = String(item.json.legacy_number || '');\n  const issuedDate = String(item.json.issued_date || '');\n  const receivedDate = String(item.json.received_date || '');\n  const corrType = String(item.json.correspondence_type || '');\n  const senderCode = String(item.json.sender || '');\n  const receiverCode = String(item.json.receiver || '');\n  const projectCode = String(item.json.project_code || '');\n\n  // JavaScript pre-mapping\n  const findOrgId = (code) => {\n    if (!code) return null;\n    const match = dbOrgs.find(o => o.code === code || o.name === code);\n    return match ? match.id : null;\n  };\n\n  const findProjectId = (code) => {\n    if (!code) return config.PROJECT_ID; // Fallback to config\n    const match = dbProjects.find(p => p.code === code || p.name === code);\n    return match ? match.id : config.PROJECT_ID;\n  };\n\n  const senderId = findOrgId(senderCode);\n  const receiverId = findOrgId(receiverCode);\n  const projectId = findProjectId(projectCode);\n  // Excel corrType is likely already the ID based on requirements, but fallback matching to ID if needed\n  const corrMatch = dbCorrTypes.find(c => String(c.id) === corrType || c.code === corrType || c.name === corrType);\n  const corrTypeId = corrMatch ? corrMatch.id : (isNaN(parseInt(corrType)) ? null : parseInt(corrType));\n\n  const isRFA = docNum.includes('-RFA-') || title.toLowerCase().includes('rfa');\n\n  const systemPrompt = `You are an expert Document Controller for a construction project (LCBP3) in Thailand.\nThe documents are primarily in THAI and ENGLISH.\nYour task is to classify documents and extract metadata from OCR text.\nRespond ONLY with valid JSON.`;\n\n  // Use pdfItem for the OCR extracted data, NOT the metaItem\n  const pdfText = String(pdfItem.json.data || '').substring(0, 3500).replace(/[^a-zA-Z0-9ก-๙\\s\\.\\/\\-:\\[\\]\\(\\)]/g, ' ');\n\n  const userPrompt = `Analyze this document:\n[EXCEL METADATA]\nDocument Number: ${docNum || 'Not provided'}\nTitle: ${title || 'Not provided'}\nIssued Date: ${issuedDate || 'Not provided'}\nReceived Date: ${receivedDate || 'Not provided'}\n\n[DATABASE REFERENCES]\nDisciplines: ${JSON.stringify(dbDisciplines)}\nTags: ${JSON.stringify(dbTags)}\n\n[OCR TEXT EXTRACTION]\n${pdfText}\n\nRules:\n1. Category: Must be one of ${JSON.stringify(systemCategories)}. If Document Number contains \"-RFA-\", category MUST be \"RFA\".\n2. Respond with EXACTLY 8 fields in JSON format:\n   - \"discipline_id\": Find 'id' from Disciplines array analyzing text to match 'th' or 'en'. If no match, use ID=64 (from contract LCBP3-C2).\n   - \"subject\": Document subject. If OCR is close to EXCEL METADATA Title, use EXCEL METADATA.\n   - \"issued_date\": Verify from OCR text if it matches ${issuedDate}, format YYYY-MM-DD.\n   - \"received_date\": Verify from OCR text. If empty, default to issued_date.\n   - \"status\": Extract status (e.g., For Information, Approve, Reject, Resubmit). This will be exported as \"remark\".\n   - \"summary\": 4-5 lines of Thai summary from OCR. This will be exported as \"body\".\n   - \"tags\": REQUIRED. Identify 2-5 main topics/themes from the document (from Title, subject matter, and OCR text). For each topic, return an object with:\n     * \"tag_name\": short topic name in Thai (2-5 words), e.g. \"คอนกรีตผสม\", \"ทดสอบวัสดุ\"\n     * \"description\": one sentence in Thai describing this topic (use key point details). e.g. \"การทดสอบค่า slump ของคอนกรีตผสมที่หน้างาน\"\n     Return as: [{\"tag_name\": \"...\", \"description\": \"...\"}, ...]\n   - \"key_points\": Array of 3-5 string key points extracted from the document (in Thai).\n\n3. IMPORTANT: You MUST REPLACE the 'null' values in the template below with the actual Integer IDs or text you found. DO NOT reply with literal 'null' if you found a match!\n\nRespond ONLY with this EXACT JSON structure:\n{\n  \"discipline_id\": 64,\n  \"subject\": \"${title}\",\n  \"issued_date\": \"${issuedDate}\",\n  \"received_date\": \"${receivedDate || issuedDate}\",\n  \"status\": null,\n  \"summary\": \"สรุปเนื้อหา 4-5 บรรทัด...\",\n  \"tags\": [{\"tag_name\": \"ชื่อหัวข้อ\", \"description\": \"คำอธิบาย key point ของหัวข้อนี้\"}],\n  \"key_points\": [\"จุดสำคัญที่ 1\", \"จุดสำคัญที่ 2\", \"จุดสำคัญที่ 3\"],\n  \"category\": \"${isRFA ? 'RFA' : 'Correspondence'}\",\n  \"confidence\": 0.95\n}`;\n\n  return {\n    json: {\n      ...item.json,\n      active_model: model,\n      is_fallback: isFallback,\n      system_categories: systemCategories,\n      pre_mapped: {\n        project_id: projectId,\n        sender_id: senderId,\n        receiver_id: receiverId,\n        correspondence_type_id: corrTypeId\n      },\n      _debug_mapping: {\n        excel_project_code: projectCode,\n        excel_sender: senderCode,\n        excel_receiver: receiverCode,\n        excel_corr_type: corrType,\n        matched_project: dbProjects.find(p => p.code === projectCode || p.name === projectCode) || null,\n        first_org_sample: dbOrgs[0] || null\n      },\n      ollama_payload: {\n        model: model,\n        prompt: `${systemPrompt}\\n\\n${userPrompt}`,\n        stream: false,\n        format: 'json',\n        options: {\n          temperature: 0.1,\n          num_ctx: 8192\n        }\n      }\n    }\n  };\n});\n"
      },
      "id": "2ba75d42-1de3-4846-a1a3-39d580e7d764",
      "name": "Build AI Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [32144, 13872],
      "notes": "สร้าง Prompt โดยใช้ Categories จาก System"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{$('Set Configuration').first().json.config.OLLAMA_HOST}}/api/generate",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.ollama_payload }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "3e8b33cb-8f8f-4d2e-b4cb-9d68cc54d96e",
      "name": "Ollama AI Analysis",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [31792, 14096],
      "notes": "เรียก Ollama วิเคราะห์เอกสาร"
    },
    {
      "parameters": {
        "jsCode": "const ollamaItems = $input.all();\nconst originalItems = $('Build AI Prompt').all();\nconst results = [];\n\n// Map AI category names → correspondence_types.type_code in DB\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 (let i = 0; i < ollamaItems.length; i++) {\n  const ollamaItem = ollamaItems[i];\n  const originalItem = originalItems[i];\n  if (!originalItem) continue;\n  const baseJson = originalItem.json;\n\n  try {\n    let raw = ollamaItem.json.response || '';\n    raw = raw.replace(/`{3}json/gi, '').replace(/`{3}/g, '').trim();\n    if (!raw) throw new Error('Empty response from AI');\n\n    const result = JSON.parse(raw);\n\n    // Map AI result to database fields\n    let tags = Array.isArray(result.tags) ? result.tags : [];\n\n    // Enum Validation for Category\n    const systemCategories = baseJson.system_categories || [];\n    let finalCategory = result.category;\n    if (!systemCategories.includes(finalCategory)) {\n      finalCategory = String(baseJson.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n    }\n\n    // Map to type_code for Backend API\n    const typeCode = CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\n\n    const preMapped = baseJson.pre_mapped || {};\n\n    results.push({\n      json: {\n        ...baseJson,\n        ai_result: {\n          suggested_category: finalCategory,\n          type_code: typeCode,\n          confidence: result.confidence || 0.8,\n          project_id: preMapped.project_id || null,\n          discipline_id: result.discipline_id || 64,\n          sender_id: preMapped.sender_id || null,\n          receiver_id: preMapped.receiver_id || null,\n          correspondence_type_id: preMapped.correspondence_type_id || null,\n          subject: result.subject || baseJson.title,\n          issued_date: result.issued_date || baseJson.issued_date,\n          received_date: result.received_date || baseJson.received_date || result.issued_date || baseJson.issued_date,\n          remark: result.status,\n          body: result.summary,\n          tags: tags,\n          key_points: result.key_points || []\n        },\n        parse_error: null\n      }\n    });\n  } catch (err) {\n    results.push({\n      json: {\n        ...baseJson,\n        ai_result: null,\n        parse_error: err.message,\n        raw_ai_response: ollamaItem.json.response,\n        error_type: 'AI_PARSE_ERROR'\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": [32000, 14096],
      "notes": "Parse JSON + Validate Schema + Enum Check"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO migration_fallback_state (batch_id, recent_error_count, is_fallback_active) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', 1, FALSE) ON DUPLICATE KEY UPDATE recent_error_count = recent_error_count + 1, is_fallback_active = CASE WHEN recent_error_count + 1 >= {{$('Set Configuration').first().json.config.FALLBACK_THRESHOLD}} THEN TRUE ELSE is_fallback_active END, updated_at = NOW()",
        "options": {}
      },
      "id": "b2ac9722-f917-42fd-81e9-c77e84b84104",
      "name": "Update Fallback State",
      "type": "n8n-nodes-base.mySql",
      "typeVersion": 2.4,
      "position": [32464, 13472],
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "notes": "เพิ่ม Error count และตรวจสอบ Fallback threshold"
    },
    {
      "parameters": {
        "jsCode": "const config = $('Set Configuration').first().json.config;\nconst items = $('Parse & Validate AI Response').all();\n\nconst results = [];\n\nfor (const item of items) {\n  const data = item.json;\n  \n  // Base structure ensuring we keep all existing data\n  let resultItem = { json: { ...data } };\n  \n  // Handle Parse Errors from upstream\n  if (data.parse_error || !data.ai_result) {\n    resultItem.json.route_index = 3;\n    results.push(resultItem);\n    continue;\n  }\n  \n  const ai = data.ai_result;\n  \n  // Revision Drift Protection\n  if (data.current_db_revision !== undefined) {\n    const expectedRev = data.current_db_revision + 1;\n    if (parseInt(data.excel_revision) !== expectedRev) {\n      resultItem.json.review_reason = `Revision drift: Excel=${data.excel_revision}, Expected=${expectedRev}`;\n      resultItem.json.route_index = 1;\n      results.push(resultItem);\n      continue;\n    }\n  }\n  \n  // Confidence Routing\n  if (ai.confidence >= config.CONFIDENCE_HIGH) {\n    resultItem.json.route_index = 0;\n  } else if (ai.confidence >= config.CONFIDENCE_LOW) {\n    resultItem.json.review_reason = `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_HIGH}`;\n    resultItem.json.route_index = 1;\n  } else {\n    resultItem.json.reject_reason = ai.is_valid === false ? 'AI marked invalid' : `Confidence ${ai.confidence.toFixed(2)} < ${config.CONFIDENCE_LOW}`;\n    resultItem.json.route_index = 2;\n  }\n  results.push(resultItem);\n}\n\nreturn results;"
      },
      "id": "ccaaee30-ead6-46c0-954a-eb8b98620cb3",
      "name": "Confidence Router",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [32160, 14096],
      "notes": "แยกตาม Confidence: Auto(≥0.85) / Review(≥0.60) / Reject(<0.60)"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/migration/import",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.document_number + ':' + $('Set Configuration').first().json.config.BATCH_ID }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.import_payload }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "4d0260de-6877-4997-8af6-8dd1becde246",
      "name": "Import to Backend",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [32704, 13664],
      "notes": "ส่งข้อมูลเข้า LCBP3 Backend พร้อม Idempotency-Key"
    },
    {
      "parameters": {
        "jsCode": "// $input here is the HTTP response from Import to Backend — it has no original_index.\n// We must go back to Build Import Payload which preserved the original item data.\nconst items = $input.all();\nconst originalItems = $('Build Import Payload').all();\n\nreturn items.map((item, i) => {\n  const originalData = originalItems[i]?.json || item.json;\n  const idx = originalData.original_index ?? 0;\n  const shouldCheckpoint = idx % 10 === 0;\n\n  return {\n    json: {\n      ...originalData,\n      import_response: item.json,\n      should_update_checkpoint: shouldCheckpoint,\n      checkpoint_index: idx,\n      import_status: 'success',\n      timestamp: new Date().toISOString()\n    }\n  };\n});\n"
      },
      "id": "929ebc49-1a4a-4d17-86b7-570bbb00dd53",
      "name": "Flag Checkpoint",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [32880, 13664],
      "notes": "กำหนดว่าจะบันทึก Checkpoint หรือไม่ (ทุก 10 records)"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO migration_progress (batch_id, last_processed_index, status) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', {{$json.checkpoint_index || 0}}, 'RUNNING') ON DUPLICATE KEY UPDATE last_processed_index = {{$json.checkpoint_index || 0}}, updated_at = NOW()",
        "options": {}
      },
      "id": "bb0e611b-db28-4266-ba40-3b5d534a16f7",
      "name": "Save Checkpoint",
      "type": "n8n-nodes-base.mySql",
      "typeVersion": 2.4,
      "position": [32928, 13856],
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "notes": "บันทึกความคืบหน้าลง Database"
    },
    {
      "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({ key_points: $json.ai_result.key_points || [], source_file_path: $json.pdf_path, raw: $json.ai_result, sender_id: $json.ai_result.sender_org_id, discipline_id: $json.ai_result.discipline_id, document_date: $json.ai_result.document_date, issued_date: $json.ai_result.issued_date, received_date: $json.ai_result.received_date, tags: $json.ai_result.suggested_tags })}}', '{{$json.review_reason}}', 'PENDING', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING', review_reason = '{{$json.review_reason}}', ai_issues = '{{JSON.stringify({ key_points: $json.ai_result.key_points || [], source_file_path: $json.pdf_path, raw: $json.ai_result, sender_id: $json.ai_result.sender_org_id, discipline_id: $json.ai_result.discipline_id, document_date: $json.ai_result.document_date, issued_date: $json.ai_result.issued_date, received_date: $json.ai_result.received_date, tags: $json.ai_result.suggested_tags })}}', created_at = NOW()",
        "options": {}
      },
      "id": "c1bd4485-e58f-4270-892e-edda34c2e328",
      "name": "Insert Review Queue",
      "type": "n8n-nodes-base.mySql",
      "typeVersion": 2.4,
      "position": [32896, 14016],
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "notes": "บันทึกรายการที่ต้องตรวจสอบโดยคน (ไม่สร้าง Correspondence)"
    },
    {
      "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,key_points\\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(JSON.stringify(item.json.ai_result?.key_points || []))\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": [32624, 14032],
      "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,raw_ai_response\\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.raw_ai_response || '')\n  ].join(',') + '\\n';\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": [32448, 14128],
      "notes": "บันทึก Error ลง CSV (จาก File Validator)"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, raw_ai_response, created_at) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', '{{$json.document_number}}', '{{$json.error_type || \"UNKNOWN\"}}', '{{$json.error || $json.parse_error}}', '{{$json.raw_ai_response || \"\"}}', NOW())",
        "options": {}
      },
      "id": "0f058ad0-3c09-4c9f-bdcf-503cd58ee395",
      "name": "Log Error to DB",
      "type": "n8n-nodes-base.mySql",
      "typeVersion": 2.4,
      "position": [32752, 14128],
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "notes": "บันทึก Error ลง MariaDB"
    },
    {
      "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": [33104, 14080],
      "webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369",
      "notes": "หน่วงเวลาระหว่าง Batches"
    },
    {
      "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 Ingest"
            },
            {
              "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 Queue"
            },
            {
              "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": "Reject"
            },
            {
              "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": [32336, 13744]
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.file_path }}",
        "options": {}
      },
      "id": "4fd3133e-39e1-4860-95c7-3e87ee43ed51",
      "name": "Read PDF File",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [31824, 13680],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "http://tika:9998/tika",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "maxPages",
              "value": "2"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "text/plain"
            },
            {
              "name": "X-Tika-OCRLanguage",
              "value": "tha+eng"
            },
            {
              "name": "X-Tika-PDFOcrStrategy",
              "value": "ocr_only"
            }
          ]
        },
        "sendBody": true,
        "contentType": "binaryData",
        "inputDataFieldName": "data",
        "options": {
          "timeout": 600000
        }
      },
      "id": "2d3868e0-ed56-4921-8d68-bf7b69a64546",
      "name": "Extract PDF Text",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [32096, 13664],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT 'projects' as type, id, project_code as text1, project_name as text2 FROM projects\nUNION ALL\nSELECT 'disciplines' as type, id, code_name_th as text1, code_name_en as text2 FROM disciplines\nUNION ALL\nSELECT 'organizations' as type, id, organization_name as text1, organization_code as text2 FROM organizations\nUNION ALL\nSELECT 'tags' as type, id, tag_name as text1, description as text2 FROM tags\nUNION ALL\nSELECT 'correspondence_types' as type, id, type_code as text1, type_name as text2 FROM correspondence_types",
        "options": {}
      },
      "id": "2e31dc54-3d57-4c88-9d35-1aba0132cdf9",
      "name": "Fetch DB Context",
      "type": "n8n-nodes-base.mySql",
      "typeVersion": 2.4,
      "position": [32000, 13872],
      "alwaysOutputData": true,
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "notes": "ดึงข้อมูลจาก Database ส่งให้ AI"
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nreturn items.map(item => {\n  const ai = item.json.ai_result || {};\n\n  let subjectStr = String(ai.subject || item.json.title || 'ไม่มีชื่อเรื่อง');\n  let categoryCode = ai.type_code || 'LETTER';\n\n  if (subjectStr.includes('ขออนุมัติ')) {\n    categoryCode = 'RFA';\n  }\n\n  let remarkStr = ai.remark || '';\n  if (item.json.legacy_number) {\n    remarkStr = (`Legacy Number: ${item.json.legacy_number}\\n` + remarkStr).trim();\n  }\n\n  return {\n    json: {\n      ...item.json,\n      import_payload: {\n        document_number: String(item.json.document_number || ''),\n        subject: subjectStr,\n        category: categoryCode,\n        source_file_path: item.json.file_path || '/dev/null',\n        ai_confidence: ai.confidence || 0.8,\n        migrated_by: 'SYSTEM_IMPORT',\n        batch_id: config.BATCH_ID,\n        project_id: Number(ai.project_id || config.PROJECT_ID),\n        discipline_id: ai.discipline_id || null,\n        sender_id: ai.sender_id || null,\n        receiver_id: ai.receiver_id || null,\n        document_date: ai.issued_date || '',\n        issued_date: ai.issued_date || '',\n        received_date: ai.received_date || '',\n        body: ai.body || '',\n        details: {\n          legacy_number: item.json.legacy_number || '',\n          remark: remarkStr,\n          key_points: ai.key_points || [],\n          tags: ai.tags || []\n        }\n      }\n    }\n  };\n});\n"
      },
      "id": "57421305-8c7e-4fa4-a339-1144902cae22",
      "name": "Build Import Payload",
      "typeVersion": 2,
      "type": "n8n-nodes-base.code",
      "position": [32544, 13664],
      "notes": "สร้าง payload สำหรับ Import to Backend"
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst results = [];\n\nfor (const itemWrapper of items) {\n  const item = itemWrapper.json;\n  const tags = item.import_payload?.details?.tags || item.ai_result?.tags || [];\n  const projectId = item.import_payload?.project_id || item.ai_result?.project_id || config.PROJECT_ID;\n  const correspondenceId = item.import_response?.id || item.import_response?.data?.id;\n\n  if (!correspondenceId || !Array.isArray(tags) || tags.length === 0) {\n    results.push({ json: { ...item, tags_upserted: 0, tag_ids_to_link: [], correspondence_id: correspondenceId } });\n    continue;\n  }\n\n  const tagIds = [];\n  const tagResults = [];\n\n  for (const tag of tags) {\n    if (!tag || !tag.tag_name) continue;\n    try {\n      const response = await $helpers.httpRequest({\n        method: 'POST',\n        url: `${config.BACKEND_URL}/api/master/tags`,\n        headers: { 'Authorization': config.MIGRATION_TOKEN, 'Content-Type': 'application/json' },\n        body: { tag_name: String(tag.tag_name), description: String(tag.description || ''), project_id: Number(projectId), color_code: 'default' },\n        json: true\n      });\n      if (response && response.id) {\n        tagIds.push(response.id);\n        tagResults.push({ tag_name: tag.tag_name, status: 'ok', id: response.id });\n      }\n    } catch (e) {\n      tagResults.push({ tag_name: tag.tag_name, status: 'error', reason: String(e.message || e) });\n    }\n  }\n\n  results.push({\n    json: {\n      ...item,\n      tags_upserted: tagResults.filter(r => r.status === 'ok').length,\n      tag_ids_to_link: tagIds,\n      correspondence_id: correspondenceId,\n      tags_result: tagResults\n    }\n  });\n}\n\nreturn results;\n"
      },
      "id": "68098411-7576-4341-8537-e557a4013377",
      "name": "Upsert Tags",
      "typeVersion": 2,
      "type": "n8n-nodes-base.code",
      "position": [32592, 13856],
      "notes": "Upsert tags หลัง import สำเร็จ"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "{{ $json.tag_ids_to_link && $json.tag_ids_to_link.length > 0 && $json.correspondence_id ? 'INSERT IGNORE INTO correspondence_tags (correspondence_id, tag_id) VALUES ' + $json.tag_ids_to_link.map(id => '(' + $json.correspondence_id + ', ' + id + ')').join(', ') + ';' : 'SELECT 1;' }}",
        "options": {}
      },
      "id": "8f998b3b-9463-4d9c-93a7-f8ed27880ba3",
      "name": "Link Tags to Correspondence",
      "typeVersion": 2.4,
      "type": "n8n-nodes-base.mySql",
      "position": [32768, 13856],
      "credentials": {
        "mySql": {
          "id": "CHHfbKhMacNo03V4",
          "name": "MySQL account"
        }
      },
      "notes": "Insert into correspondence_tags"
    }
  ],
  "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": "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
          }
        ],
        [
          {
            "node": "Check Backend Health",
            "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
          }
        ]
      ]
    },
    "Check Fallback State": {
      "main": [
        [
          {
            "node": "Fetch DB Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build AI Prompt": {
      "main": [
        [
          {
            "node": "Ollama AI Analysis",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ollama AI Analysis": {
      "main": [
        [
          {
            "node": "Parse & Validate AI Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse & Validate AI Response": {
      "main": [
        [
          {
            "node": "Confidence Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confidence Router": {
      "main": [
        [
          {
            "node": "Route by Confidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Review Queue": {
      "main": [
        [
          {
            "node": "Delay",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Reject to CSV": {
      "main": [
        [
          {
            "node": "Delay",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Import to Backend": {
      "main": [
        [
          {
            "node": "Flag Checkpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag Checkpoint": {
      "main": [
        [
          {
            "node": "Upsert Tags",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Checkpoint": {
      "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
          }
        ]
      ]
    },
    "Route by Confidence": {
      "main": [
        [
          {
            "node": "Build Import Payload",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Insert Review Queue",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Reject to CSV",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read PDF File": {
      "main": [
        [
          {
            "node": "Extract PDF Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract PDF Text": {
      "main": [
        [
          {
            "node": "Check Fallback State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch DB Context": {
      "main": [
        [
          {
            "node": "Build AI Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Import Payload": {
      "main": [
        [
          {
            "node": "Import to Backend",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Tags": {
      "main": [
        [
          {
            "node": "Link Tags to Correspondence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Link Tags to Correspondence": {
      "main": [
        [
          {
            "node": "Save Checkpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": false
  },
  "versionId": "4ec991b8-5e6e-46cd-8177-c5a21f8f6a01",
  "meta": {
    "templateCredsSetupCompleted": true,
    "instanceId": "9e70e47c1eaf3bac72f497ddfbde0983f840f7d0f059537f7e37dd70de18ecb7"
  },
  "id": "u7CLP05AyFb8Um0P",
  "tags": []
}
