690523:1623 ADR-028-228 #05
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"lint:ci": "node --max-old-space-size=4096 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\"",
|
"lint:ci": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js \"{src,apps,libs,test}/**/*.ts\" --cache",
|
||||||
"test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance",
|
"test": "jest --config jest.config.js --forceExit --testPathIgnorePatterns=tests/performance",
|
||||||
"test:debug-handles": "jest --config jest.config.js --detectOpenHandles",
|
"test:debug-handles": "jest --config jest.config.js --detectOpenHandles",
|
||||||
"test:watch": "jest --config jest.config.js --watch",
|
"test:watch": "jest --config jest.config.js --watch",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 📋 คู่มือการตั้งค่า n8n สำหรับ Legacy Data Migration (Free Plan Edition)
|
# 📋 คู่มือการตั้งค่า n8n สำหรับ Legacy Data Migration (Free Plan Edition)
|
||||||
|
|
||||||
> **สำหรับ n8n Free Plan (Self-hosted)** - ไม่ใช้ Environment Variables
|
> **สำหรับ n8n Free Plan (Self-hosted)** - ไม่ใช้ Environment Variables
|
||||||
> **Version:** 1.8.0-free | **Last Updated:** 2026-03-04
|
> **Version:** 1.9.0-free | **Last Updated:** 2026-05-23
|
||||||
|
|
||||||
เอกสารนี้จัดทำขึ้นเพื่อรองรับการ Migration เอกสาร PDF 20,000 ฉบับ ตามแผนใน `03-04-legacy-data-migration.md` และ `ADR-023A-unified-ai-architecture.md`
|
เอกสารนี้จัดทำขึ้นเพื่อรองรับการ Migration เอกสาร PDF 20,000 ฉบับ ตามแผนใน `03-04-legacy-data-migration.md` และ `ADR-023A-unified-ai-architecture.md`
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
│ ▼ ▼ │
|
│ ▼ ▼ │
|
||||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
│ │ AI Analysis │ │ Error Logger │ │
|
│ │ AI Analysis │ │ Error Logger │ │
|
||||||
│ │ (Ollama) │ │ (CSV + DB) │ │
|
│ │ (Backend API) │ │ (CSV + DB) │ │
|
||||||
│ └────────┬────────┘ └─────────────────┘ │
|
│ └────────┬────────┘ └─────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌────────▼────────┐ │
|
│ ┌────────▼────────┐ │
|
||||||
@@ -171,7 +171,7 @@ return [{ json: { config_loaded: true, timestamp: new Date().toISOString() } }];
|
|||||||
|
|
||||||
| Credential | Type | ใช้ใน Node |
|
| Credential | Type | ใช้ใน Node |
|
||||||
| ---------- | ---- | --------- ||
|
| ---------- | ---- | --------- ||
|
||||||
| Ollama API | HTTP Request | Ollama AI Analysis |
|
| ~~Ollama API~~ | ~~HTTP Request~~ | ~~ไม่ใช้แล้ว — ADR-023A~~ |
|
||||||
| LCBP3 Backend | HTTP Request | Import to Backend, Fetch Categories |
|
| LCBP3 Backend | HTTP Request | Import to Backend, Fetch Categories |
|
||||||
| MariaDB | MySQL | ทุก Database Node |
|
| MariaDB | MySQL | ทุก Database Node |
|
||||||
|
|
||||||
@@ -194,8 +194,86 @@ curl -X POST https://api.np-dms.work/api/auth/login \
|
|||||||
1. เปลี่ยน URL ให้ตรงกับ Backend ของคุณ (เช่น `http://localhost:3001/api/auth/login` สำหรับ Local)
|
1. เปลี่ยน URL ให้ตรงกับ Backend ของคุณ (เช่น `http://localhost:3001/api/auth/login` สำหรับ Local)
|
||||||
2. นำรหัสผ่านของบัญชี `migration_bot` มาใส่แทนที่ `YOUR_PASSWORD`
|
2. นำรหัสผ่านของบัญชี `migration_bot` มาใส่แทนที่ `YOUR_PASSWORD`
|
||||||
3. ในผลลัพธ์ที่ได้ ให้คัดลอกเฉพาะค่าจากฟิลด์ `access_token` (ข้อความยาวๆ)
|
3. ในผลลัพธ์ที่ได้ ให้คัดลอกเฉพาะค่าจากฟิลด์ `access_token` (ข้อความยาวๆ)
|
||||||
4. นำมาตั้งค่าใน n8n Node "Set Configuration" (Node 0) ในรูปแบบ:
|
4. นำมาตั้งค่าตามขั้นตอนด้านล่าง
|
||||||
`MIGRATION_TOKEN: 'Bearer <คัดลอก Token มาวางที่นี่>'`
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ขั้นตอนที่ 4: วิธีอัพเดต MIGRATION_TOKEN ใน n8n (ทำทุกครั้งที่ Token หมดอายุ)
|
||||||
|
|
||||||
|
> ⚠️ Token มีอายุ **≤ 7 วัน** — ต้อง Renew ทุกสัปดาห์ระหว่าง Migration Phase
|
||||||
|
|
||||||
|
**ขั้นตอน:**
|
||||||
|
|
||||||
|
**1. รับ Token ใหม่ (cURL หรือ Postman)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://backend.np-dms.work/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"migration_bot","password":"YOUR_PASSWORD"}' \
|
||||||
|
| grep -o '"access_token":"[^"]*"'
|
||||||
|
```
|
||||||
|
|
||||||
|
ผลลัพธ์ที่ได้:
|
||||||
|
```json
|
||||||
|
{ "access_token": "eyJhbGci...xxxxxx" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. เปิด n8n UI → เข้า Workflow**
|
||||||
|
|
||||||
|
```
|
||||||
|
https://n8n.np-dms.work (หรือ http://localhost:5678)
|
||||||
|
→ เปิด Workflow: "LCBP3 Migration Workflow v2.0.0"
|
||||||
|
→ คลิก Node "Set Configuration"
|
||||||
|
→ คลิก Edit
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. แก้ไขค่า `MIGRATION_TOKEN` ในโค้ด**
|
||||||
|
|
||||||
|
หาบรรทัด:
|
||||||
|
```javascript
|
||||||
|
MIGRATION_TOKEN: 'Bearer YOUR_MIGRATION_TOKEN_HERE',
|
||||||
|
```
|
||||||
|
|
||||||
|
เปลี่ยนเป็น:
|
||||||
|
```javascript
|
||||||
|
MIGRATION_TOKEN: 'Bearer eyJhbGci...xxxxxx', // ← วาง access_token ที่ได้มา
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **ต้องขึ้นต้นด้วย `Bearer ` (มีเว้นวรรค)** — ถ้าขาดจะได้ 401 ทุก request
|
||||||
|
|
||||||
|
**4. กด Save → ทดสอบ Token ก่อน Resume**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ทดสอบว่า Token ใช้งานได้
|
||||||
|
curl -H "Authorization: Bearer eyJhbGci...xxxxxx" \
|
||||||
|
https://backend.np-dms.work/api/auth/me
|
||||||
|
```
|
||||||
|
|
||||||
|
ถ้าได้ `{ "username": "migration_bot", ... }` → Token ถูกต้อง ✅
|
||||||
|
|
||||||
|
**5. Resume Workflow จาก Checkpoint**
|
||||||
|
|
||||||
|
Workflow จะอ่าน `last_processed_index` จาก `migration_progress` table และ **ทำต่อจากจุดเดิมโดยอัตโนมัติ** — ไม่ต้องรัน Excel ซ้ำตั้งแต่ต้น
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⚠️ กรณี Workflow หยุดกลางคัน (TOKEN_EXPIRED Error)
|
||||||
|
|
||||||
|
เมื่อ `Poll AI Job Status` node พบ 401 Unauthorized จะ throw:
|
||||||
|
```
|
||||||
|
TOKEN_EXPIRED: 401 Unauthorized — กรุณา renew MIGRATION_TOKEN แล้ว resume
|
||||||
|
```
|
||||||
|
|
||||||
|
**วิธีแก้:**
|
||||||
|
1. รับ Token ใหม่ (ขั้นตอนที่ 1 ด้านบน)
|
||||||
|
2. อัพเดต `Set Configuration` node (ขั้นตอนที่ 3 ด้านบน)
|
||||||
|
3. ตรวจสอบ Checkpoint ล่าสุด:
|
||||||
|
```sql
|
||||||
|
SELECT batch_id, last_processed_index, status, updated_at
|
||||||
|
FROM migration_progress
|
||||||
|
ORDER BY updated_at DESC LIMIT 5;
|
||||||
|
```
|
||||||
|
4. รัน Workflow ใหม่ด้วย Form Trigger เดิม — จะ Resume จาก `last_processed_index` ที่บันทึกไว้
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -232,7 +310,7 @@ mysql -h <DB_HOST> -u migration_bot -p lcbp3_production < lcbp3-v1.8.0-migration
|
|||||||
### Node 1: Pre-flight Checks & Data Reader
|
### Node 1: Pre-flight Checks & Data Reader
|
||||||
|
|
||||||
- **Pre-flight Token Validation (FR-010a):** เรียก API `GET /api/auth/me` ก่อนประมวลผลเพื่อตรวจสอบความถูกต้องและอายุของ `MIGRATION_TOKEN` หากไม่ผ่าน (401 Unauthorized) ให้ยุติการทำงานทันทีเพื่อป้องกันการส่ง API ที่ล้มเหลว
|
- **Pre-flight Token Validation (FR-010a):** เรียก API `GET /api/auth/me` ก่อนประมวลผลเพื่อตรวจสอบความถูกต้องและอายุของ `MIGRATION_TOKEN` หากไม่ผ่าน (401 Unauthorized) ให้ยุติการทำงานทันทีเพื่อป้องกันการส่ง API ที่ล้มเหลว
|
||||||
- ตรวจสอบ Backend Health และ Ollama Ping
|
- ตรวจสอบ Backend Health (`GET /health`) และ Token validation (`GET /api/auth/me`)
|
||||||
- อ่าน Checkpoint (`last_processed_index`) จาก `migration_progress`
|
- อ่าน Checkpoint (`last_processed_index`) จาก `migration_progress`
|
||||||
- Batch ข้อมูลจาก Excel ตามตาราง `BATCH_SIZE` ปกติ (50-100)
|
- Batch ข้อมูลจาก Excel ตามตาราง `BATCH_SIZE` ปกติ (50-100)
|
||||||
- Normalize ข้อมูล UTF-8 (NFC) และสร้าง `original_index`
|
- Normalize ข้อมูล UTF-8 (NFC) และสร้าง `original_index`
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ T+1 เดือน:
|
|||||||
| Idempotency Test: รัน Batch ซ้ำ | 0 Duplicate Records | SQL Count |
|
| Idempotency Test: รัน Batch ซ้ำ | 0 Duplicate Records | SQL Count |
|
||||||
| Organization Mapping ครบ | 100% | Lookup Table review |
|
| Organization Mapping ครบ | 100% | Lookup Table review |
|
||||||
| Frontend Review UI พร้อมใช้งาน | ✅ | UAT Passed สำหรับหน้าจออนุมัติ |
|
| Frontend Review UI พร้อมใช้งาน | ✅ | UAT Passed สำหรับหน้าจออนุมัติ |
|
||||||
| **Backend `POST /api/ai/jobs` พร้อมใช้งาน** | ✅ (Blocking) | ทดสอบ `type: migrate-document` สำเร็จ — ยังไม่มีใน Backend ต้องพัฒนาก่อน |
|
| **Backend `POST /api/ai/jobs` พร้อมใช้งาน** | ✅ (Verified 2026-05-23) | ทดสอบ `type: migrate-document` สำเร็จ — endpoint พร้อมใช้งานแล้ว |
|
||||||
| Migration Bot Token Active + Whitelisted | ✅ | API Test |
|
| Migration Bot Token Active + Whitelisted | ✅ | API Test |
|
||||||
| Staging NAS Space: ≥ 500GB free | ✅ | QNAP Dashboard |
|
| Staging NAS Space: ≥ 500GB free | ✅ | QNAP Dashboard |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# CONTEXT: N8N Workflow Refactor
|
||||||
|
|
||||||
|
> **Version:** 1.0.0 | **Last Updated:** 2026-05-23
|
||||||
|
> **Status:** APPROVED — Ready for Implementation
|
||||||
|
> **Related:** `03-05-n8n-migration-setup-guide.md`, `ADR-023A`, `ADR-028`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ขอบเขต (Scope)
|
||||||
|
|
||||||
|
n8n มีหน้าที่เดียว: **Migration Legacy Documents เท่านั้น**
|
||||||
|
|
||||||
|
| Use Case | Pipeline | ผ่าน n8n? |
|
||||||
|
|---|---|---|
|
||||||
|
| Migrate Legacy Documents (Excel + PDF) | **Pipeline A** | ✅ ใช่ |
|
||||||
|
| New Correspondence จาก Frontend | **Pipeline B** | ❌ ไม่ใช่ — Backend/BullMQ เท่านั้น |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pipeline A — Migration Legacy Documents (n8n)
|
||||||
|
|
||||||
|
### 2.1 ภาพรวม Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
n8n (Form Trigger)
|
||||||
|
│
|
||||||
|
├─► Set Configuration (BATCH_ID, BACKEND_URL, MIGRATION_TOKEN, paths)
|
||||||
|
│
|
||||||
|
├─► Pre-flight Checks
|
||||||
|
│ ├─ GET /api/auth/me (validate token)
|
||||||
|
│ ├─ GET /health (backend health)
|
||||||
|
│ ├─ File Mount Check (Excel + PDF dir exists)
|
||||||
|
│ └─ Fetch Master Data (categories, tags, projects, orgs)
|
||||||
|
│
|
||||||
|
├─► Read Excel → Batch (BATCH_SIZE records)
|
||||||
|
│ └─ Resume from Checkpoint (migration_progress table)
|
||||||
|
│
|
||||||
|
├─► [Per Record Loop]
|
||||||
|
│ │
|
||||||
|
│ ├─► File Validator (PDF exists on disk)
|
||||||
|
│ │
|
||||||
|
│ ├─► POST /api/storage/upload
|
||||||
|
│ │ → temp_attachment_id
|
||||||
|
│ │
|
||||||
|
│ ├─► POST /api/ai/jobs
|
||||||
|
│ │ type: "migrate-document"
|
||||||
|
│ │ payload: {
|
||||||
|
│ │ tempAttachmentId,
|
||||||
|
│ │ documentNumber, ← จาก Excel
|
||||||
|
│ │ title, ← จาก Excel
|
||||||
|
│ │ batchId,
|
||||||
|
│ │ existingTags[], ← จาก master data fetch
|
||||||
|
│ │ systemCategories[] ← จาก master data fetch
|
||||||
|
│ │ }
|
||||||
|
│ │ Idempotency-Key: "{batchId}:{documentNumber}"
|
||||||
|
│ │ → { jobId }
|
||||||
|
│ │
|
||||||
|
│ ├─► GET /api/ai/jobs/{jobId}
|
||||||
|
│ │ Poll ทุก 5 วินาที (timeout 120 วินาที)
|
||||||
|
│ │ รอ status = "completed"
|
||||||
|
│ │
|
||||||
|
│ ├─► Parse & Validate AI Response
|
||||||
|
│ │
|
||||||
|
│ ├─► Confidence Router (4 สาย)
|
||||||
|
│ │ ├─ ≥ 0.85 + is_valid → PENDING (Auto Ready) → migration_review_queue
|
||||||
|
│ │ ├─ 0.60–0.84 → PENDING (Flagged) → migration_review_queue
|
||||||
|
│ │ ├─ < 0.60 / is_valid=F → REJECTED → migration_review_queue
|
||||||
|
│ │ └─ Parse Error → Error Log (CSV + DB)
|
||||||
|
│ │
|
||||||
|
│ └─► Checkpoint (ทุก 10 records)
|
||||||
|
│
|
||||||
|
└─► [Loop: Delay → Read Checkpoint → Next Batch → ...]
|
||||||
|
Exit condition: ไม่มี records เหลือ (allItems.length === 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Excel Metadata ที่ส่งไปพร้อม AI Job
|
||||||
|
|
||||||
|
Excel metadata ส่งไปเป็น **context** ให้ AI — AI ยังคงทำ OCR จาก PDF ด้วย:
|
||||||
|
|
||||||
|
| Excel Column | DTO Field | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `document_number` | `documentNumber` | Required — Idempotency Key |
|
||||||
|
| `title` / `Subject` | `title` | Pre-fill subject suggestion |
|
||||||
|
| `Batch Size` / `batch_id` | `batchId` | Idempotency grouping |
|
||||||
|
| existing tags (from DB) | `existingTags[]` | AI match ก่อนสร้างใหม่ |
|
||||||
|
| categories (from API) | `systemCategories[]` | Constrain AI classification |
|
||||||
|
|
||||||
|
> **หมายเหตุ:** `sender`, `receiver`, `issued_date` จาก Excel ใช้เป็น context ใน Backend prompt — Backend รับผิดชอบ prompt construction (ไม่ใช่ n8n)
|
||||||
|
|
||||||
|
### 2.3 AI Job Endpoint (ที่มีอยู่แล้วใน Backend)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/ai/jobs
|
||||||
|
Authorization: Bearer {MIGRATION_TOKEN}
|
||||||
|
Idempotency-Key: {batchId}:{documentNumber}
|
||||||
|
Body: SubmitAiJobDto {
|
||||||
|
type: "migrate-document",
|
||||||
|
payload: MigrateDocumentPayloadDto
|
||||||
|
}
|
||||||
|
→ HTTP 202 Accepted: { jobId: string }
|
||||||
|
|
||||||
|
GET /api/ai/jobs/{jobId}
|
||||||
|
→ { status: "completed" | "processing" | "failed", result: {...} }
|
||||||
|
```
|
||||||
|
|
||||||
|
> ✅ **Endpoint นี้มีอยู่แล้วใน Backend** — ไม่ต้องสร้างใหม่ (verified 2026-05-23)
|
||||||
|
|
||||||
|
### 2.4 สิ่งที่ n8n ไม่ทำ (ADR-023A)
|
||||||
|
|
||||||
|
- ❌ ไม่ call Ollama โดยตรง (`/api/generate`)
|
||||||
|
- ❌ ไม่ call Qdrant โดยตรง
|
||||||
|
- ❌ ไม่ทำ OCR เอง — Backend BullMQ Worker รับผิดชอบ
|
||||||
|
- ❌ ไม่ INSERT ตรงลง `correspondences` table — ต้องผ่าน Human Review
|
||||||
|
|
||||||
|
### 2.5 Human Review Flow (หลัง n8n เสร็จ)
|
||||||
|
|
||||||
|
```
|
||||||
|
migration_review_queue (PENDING)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SUPERADMIN/ADMIN เปิด Frontend Review UI
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Review → Approve / Edit / Reject
|
||||||
|
│
|
||||||
|
▼ (Approve)
|
||||||
|
POST /api/migration/review/{id}/commit
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Backend INSERT → correspondences table
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Auto-trigger: RAG embed (parallel, BullMQ ai-batch)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pipeline B — New Correspondence (Frontend/Backend เท่านั้น)
|
||||||
|
|
||||||
|
### 3.1 ภาพรวม Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User อัพโหลด PDF ใน Frontend
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/storage/upload → temp_attachment_id
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Backend BullMQ ai-realtime
|
||||||
|
→ OCR (PyMuPDF / PaddleOCR)
|
||||||
|
→ AI Metadata Extraction (gemma4:e4b Q8_0)
|
||||||
|
→ Tag Suggestion (Existing + New)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend แสดง AI Suggestions (editable form)
|
||||||
|
- subject (แก้ไขได้)
|
||||||
|
- category (แก้ไขได้)
|
||||||
|
- discipline (แก้ไขได้)
|
||||||
|
- tags[] (แก้ไขได้ — เพิ่ม/ลบ/เปลี่ยน)
|
||||||
|
- confidence score (แสดงเท่านั้น)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
User review & approve (กด Submit)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/correspondences (พร้อม temp_attachment_id + metadata)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Backend commit → correspondences table
|
||||||
|
Auto-trigger: RAG embed (parallel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 AI Tag Suggestion — ทาง C
|
||||||
|
|
||||||
|
AI แนะนำ Tags แบบ 2 ชั้น:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TagSuggestion {
|
||||||
|
tagName: string;
|
||||||
|
colorCode?: string;
|
||||||
|
isNew: boolean; // true = tag ใหม่ที่ AI แนะนำ, false = existing tag
|
||||||
|
publicId?: string; // มีเฉพาะ isNew = false (ADR-019)
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| `isNew` | หมายความว่า | UI แสดง |
|
||||||
|
|---|---|---|
|
||||||
|
| `false` | match กับ existing tag ในโปรเจกต์ | chip สีตาม tag |
|
||||||
|
| `true` | AI แนะนำ tag ใหม่ที่ไม่มีในระบบ | chip สี default + icon "new" |
|
||||||
|
|
||||||
|
User สามารถ:
|
||||||
|
- ✅ Accept existing tag suggestion
|
||||||
|
- ✅ Accept new tag suggestion (สร้างใหม่เมื่อ commit)
|
||||||
|
- ✅ Remove tag ที่ AI แนะนำ
|
||||||
|
- ✅ Add tag เองแบบ manual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. สิ่งที่ต้องอัพเดตใน Backend (ถ้าต้องการ Pipeline B สมบูรณ์)
|
||||||
|
|
||||||
|
| Component | สถานะ | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/ai/jobs` (type: migrate-document) | ✅ พร้อม | verified |
|
||||||
|
| `GET /api/ai/jobs/{jobId}` polling | ✅ พร้อม | verified |
|
||||||
|
| `POST /api/storage/upload` | ✅ พร้อม | two-phase upload |
|
||||||
|
| AI Tag Suggestion ใน ai-realtime response | ⚠️ ตรวจสอบ | ต้อง return `suggestedTags[]` พร้อม `isNew` flag |
|
||||||
|
| Frontend Editable Review Form | ⚠️ ตรวจสอบ | pre-fill + tag suggestion UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Decisions Log (จาก QuizMe Session 2026-05-23)
|
||||||
|
|
||||||
|
| # | คำถาม | คำตอบ |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 | n8n รองรับ 2 mode? | ไม่ — n8n = Migration เท่านั้น |
|
||||||
|
| S2 | New Correspondence ผ่าน n8n? | ไม่ — Backend BullMQ เท่านั้น |
|
||||||
|
| S3 | n8n call Ollama ตรง? | ไม่ — เปลี่ยนเป็น Backend API (`POST /api/ai/jobs`) |
|
||||||
|
| S4 | Loop exit condition? | ออกจาก scope (migration only) |
|
||||||
|
| S5 | Admin monitoring? | ออกจาก scope |
|
||||||
|
| PA | Excel metadata ส่งไปกับ AI job? | ✅ ใช่ — เป็น context ให้ AI |
|
||||||
|
| PB-tags | Tag suggestion mode? | ทาง C — existing match + สร้างใหม่ได้ |
|
||||||
|
| PB-UX | User approve form? | Editable — แก้ไข AI suggestions ได้ก่อน submit |
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
{
|
||||||
|
"name": "LCBP3 Migration Workflow v2.0.0",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"formTitle": "LCBP3 Migration - ตั้งค่าก่อนรัน",
|
||||||
|
"formDescription": "กรุณาตั้งค่า Batch และ Excel file ก่อนรัน Migration",
|
||||||
|
"formFields": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"fieldLabel": "Batch Size",
|
||||||
|
"fieldType": "number",
|
||||||
|
"placeholder": "10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldLabel": "Excel File Path",
|
||||||
|
"placeholder": "/home/node/.n8n-files/staging_ai/C22024.xlsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "4609ab68-f7e4-4800-ad39-19ce32de60d0",
|
||||||
|
"name": "Form Trigger",
|
||||||
|
"type": "n8n-nodes-base.formTrigger",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [31024, 13504],
|
||||||
|
"webhookId": "e164a362-0c6b-4243-a5ad-b325aa943f4f",
|
||||||
|
"notes": "เปิด URL เพื่อตั้งค่าก่อนรัน"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// ============================================\n// CONFIGURATION v2.0 — ADR-023A Compliant\n// n8n เรียกแค่ DMS Backend API เท่านั้น — ห้ามเรียก Ollama โดยตรง\n// ============================================\nconst formData = $('Form Trigger').first()?.json || {};\nconst batchSizeInput = parseInt(String(formData['Batch Size'] || '0'));\nconst excelFileInput = String(formData['Excel File Path'] || '').trim();\n\nconst BATCH_ID = (() => {\n const d = new Date(Date.now() + 7 * 3600000);\n const s = d.toISOString();\n return s.substring(0,10).replace(/-/g,'') + ':' + s.substring(11,16).replace(/:/g,'');\n})();\n\nconst CONFIG = {\n // Backend Settings\n BACKEND_URL: 'https://backend.np-dms.work',\n MIGRATION_TOKEN: 'Bearer YOUR_MIGRATION_TOKEN_HERE', // 🔴 เปลี่ยน\n\n // Batch Settings\n BATCH_SIZE: batchSizeInput > 0 ? batchSizeInput : 10,\n BATCH_ID: BATCH_ID,\n DELAY_MS: 2000,\n\n // AI Job Settings (ADR-023A)\n AI_JOB_POLL_INTERVAL_MS: 5000,\n AI_JOB_TIMEOUT_MS: 120000,\n\n // Confidence Thresholds\n CONFIDENCE_HIGH: 0.85,\n CONFIDENCE_LOW: 0.60,\n\n // Paths (Container paths — ต้องตรงกับ volume mount ใน docker-compose)\n EXCEL_FILE: excelFileInput || '/home/node/.n8n-files/staging_ai/C22024.xlsx',\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai',\n LOG_PATH: '/home/node/.n8n-files/migration_logs',\n};\n\nreturn [{ json: { config: CONFIG } }];"
|
||||||
|
},
|
||||||
|
"id": "8f1d3378-cca6-48b6-99db-693e46ac81ef",
|
||||||
|
"name": "Set Configuration",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [31216, 13504],
|
||||||
|
"notes": "กำหนดค่า Configuration ทั้งหมด — แก้ไขที่นี่เท่านั้น (ไม่มี Ollama config แล้ว)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/health",
|
||||||
|
"options": {
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "60e81de6-e9b2-4bff-afcc-bef9d5b959b5",
|
||||||
|
"name": "Check Backend Health",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [31392, 13504],
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "ตรวจสอบ Backend พร้อมใช้งาน"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/auth/me",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"name": "Validate Token",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [31392, 13696],
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "FR-010a: ตรวจสอบ MIGRATION_TOKEN ก่อนเริ่ม Batch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/correspondence-types",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "6c6679b4-85f3-4c2c-ac8e-4281d6ae61f6",
|
||||||
|
"name": "Fetch Categories",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [31568, 13504],
|
||||||
|
"notes": "ดึง Categories จาก Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/tags",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 10000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "98b9159a-f21d-4b33-9524-058a78ccfc93",
|
||||||
|
"name": "Fetch Tags",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [31568, 13696],
|
||||||
|
"notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\ntry {\n if (!fs.existsSync(config.EXCEL_FILE)) {\n throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n }\n if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n }\n \n const files = fs.readdirSync(config.SOURCE_PDF_DIR);\n \n // Check write permission to log path\n fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString());\n \n // Grab categories\n let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n try {\n const upstreamData = $('Fetch Categories').first()?.json?.data;\n if (upstreamData && Array.isArray(upstreamData)) {\n categories = upstreamData.map(c => c.name || c.type || c);\n }\n } catch(e) {}\n \n // Grab existing tags\n let existingTags = [];\n try {\n const tagData = $('Fetch Tags').first()?.json?.data || [];\n existingTags = Array.isArray(tagData)\n ? tagData.map(t => ({ publicId: t.id, tagName: t.tag_name || t.name || '' })).filter(t => t.tagName)\n : [];\n } catch(e) {}\n \n return [{ json: { \n preflight_ok: true, \n pdf_count_in_source: files.length,\n excel_target: config.EXCEL_FILE,\n system_categories: categories,\n existing_tags: existingTags,\n timestamp: new Date().toISOString()\n }}];\n} catch (err) {\n throw new Error(`Pre-flight check failed: ${err.message}`);\n}"
|
||||||
|
},
|
||||||
|
"id": "910b13e2-994a-4fb6-bca1-637e1628c586",
|
||||||
|
"name": "File Mount Check",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [31744, 13504],
|
||||||
|
"notes": "ตรวจสอบ File System + รวบรวม master data สำหรับ AI payload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"fileSelector": "={{ $json.excel_target }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "063bcef1-791a-4923-a659-8b9a0ba3e336",
|
||||||
|
"name": "Read Excel Binary",
|
||||||
|
"type": "n8n-nodes-base.readWriteFile",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [31920, 13504],
|
||||||
|
"notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "e07efdde-b9b1-402a-ba01-44175982749b",
|
||||||
|
"name": "Read Excel",
|
||||||
|
"type": "n8n-nodes-base.spreadsheetFile",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [31920, 13696],
|
||||||
|
"notes": "แปลงข้อมูล Excel เป็น JSON Data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT last_processed_index, status FROM migration_progress WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "a83f8598-72fd-4cc8-9d98-1ea3cb3b42df",
|
||||||
|
"name": "Read Checkpoint",
|
||||||
|
"type": "n8n-nodes-base.mySql",
|
||||||
|
"typeVersion": 2.4,
|
||||||
|
"position": [32096, 13504],
|
||||||
|
"alwaysOutputData": true,
|
||||||
|
"credentials": {
|
||||||
|
"mySql": {
|
||||||
|
"id": "CHHfbKhMacNo03V4",
|
||||||
|
"name": "MySQL account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "อ่านตำแหน่งล่าสุดที่ประมวลผล"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.last_processed_index || 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, config.BATCH_SIZE);\n\n// Exit condition: ไม่มี records เหลือ\nif (currentBatch.length === 0) {\n return [{ json: { batch_complete: true, total_processed: startIndex } }];\n}\n\nconst normalize = (str) => {\n if (!str) return '';\n return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n const getVal = (possibleKeys) => {\n const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n if (exactMatch) return item[exactMatch];\n const lowerTrimmedKeys = Object.keys(item).map(k => ({ original: k, parsed: k.toLowerCase().trim() }));\n for (const pk of possibleKeys) {\n const found = lowerTrimmedKeys.find(k => k.parsed === pk.toLowerCase().trim());\n if (found) return item[found.original];\n }\n return '';\n };\n\n const docNum = getVal(['document_number', 'correspondence_number', 'Document Number', 'Corr. No.']);\n const excelFileName = getVal(['File name', 'file_name', 'File Name', 'filename']);\n \n if (!excelFileName) {\n throw new Error(`Missing 'File name' column for row ${i + startIndex + 1}, document: ${docNum}`);\n }\n \n return {\n json: {\n batch_complete: false,\n document_number: normalize(docNum),\n title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n excel_revision: getVal(['revision', 'Revision', 'rev']) || 1,\n original_index: startIndex + i,\n batch_id: config.BATCH_ID,\n file_name: normalize(excelFileName),\n issued_date: normalize(getVal(['issued_date', 'Issued_date', 'Issued Date', 'date', 'Date'])),\n received_date: normalize(getVal(['received_date', 'Received_date', 'Received Date'])),\n sender: normalize(getVal(['sender', 'Sender', 'From', 'from'])),\n receiver: normalize(getVal(['receiver', 'Receiver', 'To', 'to'])),\n }\n };\n});"
|
||||||
|
},
|
||||||
|
"id": "80845e32-c283-4e9f-af73-6339d675fb38",
|
||||||
|
"name": "Process Batch + Encoding",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [32272, 13504],
|
||||||
|
"alwaysOutputData": true,
|
||||||
|
"notes": "ตัด Batch + Normalize UTF-8 + Exit condition เมื่อ records หมด"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\n// ตรวจสอบ exit condition\nif (items[0]?.json?.batch_complete === true) {\n return items;\n}\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n const fileName = item.json?.file_name;\n if (!fileName) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME' } });\n continue;\n }\n \n let safeName = path.basename(String(fileName)).normalize('NFC');\n if (!safeName.toLowerCase().endsWith('.pdf')) safeName += '.pdf';\n const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n \n if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY' } });\n continue;\n }\n \n try {\n if (fs.existsSync(filePath)) {\n const stats = fs.statSync(filePath);\n validated.push({ ...item, json: { ...item.json, file_valid: true, file_path: filePath, file_size: stats.size } });\n } else {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND' } });\n }\n } catch (err) {\n errors.push({ ...item, json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR' } });\n }\n}\n\nreturn [...validated, ...errors];"
|
||||||
|
},
|
||||||
|
"id": "2183d687-4708-4d77-a0a9-13ccf29baf69",
|
||||||
|
"name": "File Validator",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [32448, 13504],
|
||||||
|
"notes": "ตรวจสอบไฟล์ PDF ใน Directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"fileSelector": "={{ $json.file_path }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "4fd3133e-39e1-4860-95c7-3e87ee43ed51",
|
||||||
|
"name": "Read PDF File",
|
||||||
|
"type": "n8n-nodes-base.readWriteFile",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [32624, 13504],
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "อ่าน PDF Binary เพื่อเตรียม Upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/storage/upload",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "multipart-form-data",
|
||||||
|
"bodyParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"parameterType": "formBinaryData",
|
||||||
|
"name": "file",
|
||||||
|
"inputDataFieldName": "data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"timeout": 60000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "b1c2d3e4-f5a6-7890-bcde-f12345678901",
|
||||||
|
"name": "Upload PDF to Backend",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"position": [32800, 13504],
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "ADR-023A: อัพโหลด PDF เข้า Backend Temp Storage → รับ temp_attachment_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const config = $('Set Configuration').first().json.config;\nconst uploadResponse = $input.first()?.json || {};\nconst metaItem = $('File Validator').all()[$input.context?.itemIndex ?? 0]?.json || {};\nconst mountCheckData = $('File Mount Check').first()?.json || {};\n\nconst tempAttachmentId = uploadResponse?.data?.id || uploadResponse?.id;\nif (!tempAttachmentId) {\n throw new Error(`Upload failed — no temp_attachment_id returned for document: ${metaItem.document_number}`);\n}\n\n// สร้าง SubmitAiJobDto ตาม Backend DTO\nconst submitPayload = {\n type: 'migrate-document',\n payload: {\n tempAttachmentId: String(tempAttachmentId),\n documentNumber: String(metaItem.document_number || ''),\n title: String(metaItem.title || ''),\n batchId: String(config.BATCH_ID),\n existingTags: mountCheckData.existing_tags || [],\n systemCategories: mountCheckData.system_categories || [],\n }\n};\n\n// Idempotency-Key: deterministic format ตาม FR-001a\nconst idempotencyKey = `${config.BATCH_ID}:${metaItem.document_number}`;\n\nreturn [{\n json: {\n ...metaItem,\n temp_attachment_id: tempAttachmentId,\n submit_payload: submitPayload,\n idempotency_key: idempotencyKey,\n }\n}];"
|
||||||
|
},
|
||||||
|
"id": "c2d3e4f5-a6b7-8901-cdef-234567890123",
|
||||||
|
"name": "Build AI Job Payload",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [32976, 13504],
|
||||||
|
"notes": "สร้าง SubmitAiJobDto payload + Idempotency-Key ตาม FR-001a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/jobs",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Authorization",
|
||||||
|
"value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Idempotency-Key",
|
||||||
|
"value": "={{ $json.idempotency_key }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ $json.submit_payload }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 30000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "d3e4f5a6-b7c8-9012-defa-345678901234",
|
||||||
|
"name": "Submit AI Job",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.1,
|
||||||
|
"position": [33152, 13504],
|
||||||
|
"onError": "continueErrorOutput",
|
||||||
|
"notes": "ADR-023A: POST /api/ai/jobs — Backend จัดการ Ollama ผ่าน BullMQ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Poll GET /api/ai/jobs/{jobId} ทุก 5 วินาที จน status = completed หรือ timeout 120 วินาที\nconst config = $('Set Configuration').first().json.config;\nconst submitResponse = $input.first()?.json || {};\nconst originalMeta = $('Build AI Job Payload').all()[$input.context?.itemIndex ?? 0]?.json || {};\n\nconst jobId = submitResponse?.jobId || submitResponse?.data?.jobId;\nif (!jobId) {\n // FR-010b: ถ้า 401 → mark TOKEN_EXPIRED\n if (submitResponse?.statusCode === 401 || submitResponse?.error?.status === 401) {\n throw new Error('TOKEN_EXPIRED: 401 Unauthorized — กรุณา renew MIGRATION_TOKEN แล้ว resume');\n }\n throw new Error(`Submit AI Job failed — no jobId returned for: ${originalMeta.document_number}`);\n}\n\nconst pollIntervalMs = config.AI_JOB_POLL_INTERVAL_MS || 5000;\nconst timeoutMs = config.AI_JOB_TIMEOUT_MS || 120000;\nconst startTime = Date.now();\n\nwhile (true) {\n if (Date.now() - startTime > timeoutMs) {\n throw new Error(`AI Job timeout after ${timeoutMs}ms for jobId: ${jobId}, document: ${originalMeta.document_number}`);\n }\n \n const statusResponse = await $helpers.httpRequest({\n method: 'GET',\n url: `${config.BACKEND_URL}/api/ai/jobs/${jobId}`,\n headers: { 'Authorization': config.MIGRATION_TOKEN },\n json: true\n });\n \n const status = statusResponse?.status || statusResponse?.data?.status;\n \n if (status === 'completed') {\n return [{\n json: {\n ...originalMeta,\n job_id: jobId,\n ai_result: statusResponse?.result || statusResponse?.data?.result || {},\n ai_status: 'completed',\n }\n }];\n }\n \n if (status === 'failed') {\n return [{\n json: {\n ...originalMeta,\n job_id: jobId,\n ai_status: 'failed',\n parse_error: `AI Job failed: ${JSON.stringify(statusResponse?.error || statusResponse?.data?.error || 'unknown')}`,\n error_type: 'AI_JOB_FAILED',\n route_index: 3\n }\n }];\n }\n \n // status = 'processing' — รอแล้ว poll ใหม่\n await new Promise(resolve => setTimeout(resolve, pollIntervalMs));\n}"
|
||||||
|
},
|
||||||
|
"id": "e4f5a6b7-c8d9-0123-efab-456789012345",
|
||||||
|
"name": "Poll AI Job Status",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [33328, 13504],
|
||||||
|
"notes": "Poll GET /api/ai/jobs/{jobId} ทุก 5 วินาที (timeout 120 วินาที)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const config = $('Set Configuration').first().json.config;\nconst items = $input.all();\nconst results = [];\n\nconst CATEGORY_TO_TYPE_CODE = {\n 'Correspondence': 'LETTER',\n 'RFA': 'RFA',\n 'Transmittal': 'TRANSMITTAL',\n 'Drawing': 'OTHER',\n 'Report': 'OTHER',\n 'Other': 'OTHER',\n};\n\nfor (const item of items) {\n const data = item.json;\n \n // ถ้า poll ส่ง error กลับมาโดยตรง (route_index = 3)\n if (data.route_index === 3 || data.ai_status === 'failed') {\n results.push({ json: { ...data, route_index: 3 } });\n continue;\n }\n \n const ai = data.ai_result || {};\n \n if (!ai || typeof ai !== 'object') {\n results.push({ json: { ...data, parse_error: 'Empty or invalid ai_result', error_type: 'PARSE_ERROR', route_index: 3 } });\n continue;\n }\n \n // Enum Validation for Category\n const systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n let finalCategory = ai.suggested_category || ai.category || 'Correspondence';\n if (!systemCategories.includes(finalCategory)) {\n finalCategory = String(data.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n const typeCode = CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\n \n // Tag normalization — is_new flag\n const suggestedTags = Array.isArray(ai.suggested_tags)\n ? ai.suggested_tags.map(t => ({\n tagName: String(t.tagName || t.tag_name || ''),\n publicId: t.publicId || t.public_id || undefined,\n isNew: Boolean(t.isNew ?? t.is_new ?? !t.publicId),\n colorCode: t.colorCode || t.color_code || 'default',\n })).filter(t => t.tagName)\n : [];\n \n const confidence = Number(ai.confidence || 0);\n \n const normalizedAi = {\n ...ai,\n suggested_category: finalCategory,\n type_code: typeCode,\n confidence,\n suggested_tags: suggestedTags,\n };\n \n let route_index;\n let review_reason = '';\n let reject_reason = '';\n \n if (confidence >= config.CONFIDENCE_HIGH && ai.is_valid !== false) {\n route_index = 0; // Auto Ready\n } else if (confidence >= config.CONFIDENCE_LOW) {\n route_index = 1; // Flagged\n review_reason = `Confidence ${confidence.toFixed(2)} < ${config.CONFIDENCE_HIGH}`;\n } else {\n route_index = 2; // Rejected\n reject_reason = ai.is_valid === false ? 'AI marked invalid' : `Confidence ${confidence.toFixed(2)} < ${config.CONFIDENCE_LOW}`;\n }\n \n results.push({\n json: {\n ...data,\n ai_result: normalizedAi,\n route_index,\n review_reason,\n reject_reason,\n }\n });\n}\n\nreturn results;"
|
||||||
|
},
|
||||||
|
"id": "6716162f-1129-4552-a05f-a08ac115fe10",
|
||||||
|
"name": "Parse & Validate AI Response",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [33504, 13504],
|
||||||
|
"notes": "Parse AI result + Confidence routing prep + Tag normalization (isNew flag)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rules": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
|
||||||
|
"conditions": [{ "leftValue": "={{ $json.route_index }}", "rightValue": 0, "operator": { "type": "number", "operation": "equals", "singleValue": true } }],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"renameOutput": true,
|
||||||
|
"outputKey": "Auto Ready"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
|
||||||
|
"conditions": [{ "leftValue": "={{ $json.route_index }}", "rightValue": 1, "operator": { "type": "number", "operation": "equals", "singleValue": true } }],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"renameOutput": true,
|
||||||
|
"outputKey": "Review Flagged"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
|
||||||
|
"conditions": [{ "leftValue": "={{ $json.route_index }}", "rightValue": 2, "operator": { "type": "number", "operation": "equals", "singleValue": true } }],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"renameOutput": true,
|
||||||
|
"outputKey": "Rejected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
|
||||||
|
"conditions": [{ "leftValue": "={{ $json.route_index }}", "rightValue": 3, "operator": { "type": "number", "operation": "equals", "singleValue": true } }],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"renameOutput": true,
|
||||||
|
"outputKey": "Error Log"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "65f0bb6c-496a-4409-8b88-3132866cf9a4",
|
||||||
|
"name": "Route by Confidence",
|
||||||
|
"type": "n8n-nodes-base.switch",
|
||||||
|
"typeVersion": 3.2,
|
||||||
|
"position": [33680, 13504]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO migration_review_queue (document_number, title, original_title, ai_suggested_category, ai_confidence, ai_issues, review_reason, status, created_at) VALUES ('{{$json.document_number}}', '{{$json.ai_result.subject || $json.title}}', '{{$json.title}}', '{{$json.ai_result.suggested_category}}', {{$json.ai_result.confidence}}, '{{JSON.stringify({ job_id: $json.job_id, temp_attachment_id: $json.temp_attachment_id, type_code: $json.ai_result.type_code, subject: $json.ai_result.subject, suggested_tags: $json.ai_result.suggested_tags, key_points: $json.ai_result.key_points || [], issued_date: $json.ai_result.issued_date, received_date: $json.ai_result.received_date })}}', '{{$json.review_reason || \"\"}}', 'PENDING', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING', review_reason = '{{$json.review_reason || \"\"}}', ai_issues = VALUES(ai_issues), created_at = NOW()",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "c1bd4485-e58f-4270-892e-edda34c2e328",
|
||||||
|
"name": "Insert Review Queue (Auto)",
|
||||||
|
"type": "n8n-nodes-base.mySql",
|
||||||
|
"typeVersion": 2.4,
|
||||||
|
"position": [33856, 13312],
|
||||||
|
"credentials": {
|
||||||
|
"mySql": {
|
||||||
|
"id": "CHHfbKhMacNo03V4",
|
||||||
|
"name": "MySQL account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": "Auto Ready (confidence ≥ 0.85) → migration_review_queue PENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO migration_review_queue (document_number, title, original_title, ai_suggested_category, ai_confidence, ai_issues, review_reason, status, created_at) VALUES ('{{$json.document_number}}', '{{$json.ai_result.subject || $json.title}}', '{{$json.title}}', '{{$json.ai_result.suggested_category}}', {{$json.ai_result.confidence}}, '{{JSON.stringify({ job_id: $json.job_id, temp_attachment_id: $json.temp_attachment_id, type_code: $json.ai_result.type_code, subject: $json.ai_result.subject, suggested_tags: $json.ai_result.suggested_tags, key_points: $json.ai_result.key_points || [], issued_date: $json.ai_result.issued_date, received_date: $json.ai_result.received_date })}}', '{{$json.review_reason}}', 'PENDING_REVIEW', NOW()) ON DUPLICATE KEY UPDATE status = 'PENDING_REVIEW', review_reason = '{{$json.review_reason}}', ai_issues = VALUES(ai_issues), created_at = NOW()",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "f1a2b3c4-d5e6-7890-abcd-567890123456",
|
||||||
|
"name": "Insert Review Queue (Flagged)",
|
||||||
|
"type": "n8n-nodes-base.mySql",
|
||||||
|
"typeVersion": 2.4,
|
||||||
|
"position": [33856, 13504],
|
||||||
|
"credentials": {
|
||||||
|
"mySql": {
|
||||||
|
"id": "CHHfbKhMacNo03V4",
|
||||||
|
"name": "MySQL account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": "Flagged (confidence 0.60–0.84) → migration_review_queue PENDING_REVIEW"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.title),\n esc(item.json.reject_reason),\n item.json.ai_result?.confidence ?? 'N/A',\n esc(item.json.job_id || '')\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];"
|
||||||
|
},
|
||||||
|
"id": "0bb3530f-02d5-44d0-ad94-c94d97d91b6a",
|
||||||
|
"name": "Log Reject to CSV",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [33856, 13696],
|
||||||
|
"notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/error_log.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n const line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.error_type || 'UNKNOWN'),\n esc(item.json.error || item.json.parse_error),\n esc(item.json.job_id || '')\n ].join(',') + '\\n';\n fs.appendFileSync(csvPath, line, 'utf8');\n}\n\nreturn items;"
|
||||||
|
},
|
||||||
|
"id": "8250dd88-ca81-45aa-93d8-480c9bcd6b14",
|
||||||
|
"name": "Log Error to CSV",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [33856, 13888],
|
||||||
|
"notes": "บันทึก Error ลง CSV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, created_at) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', '{{$json.document_number}}', '{{$json.error_type || \"UNKNOWN\"}}', '{{$json.error || $json.parse_error}}', NOW())",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "0f058ad0-3c09-4c9f-bdcf-503cd58ee395",
|
||||||
|
"name": "Log Error to DB",
|
||||||
|
"type": "n8n-nodes-base.mySql",
|
||||||
|
"typeVersion": 2.4,
|
||||||
|
"position": [34032, 13888],
|
||||||
|
"credentials": {
|
||||||
|
"mySql": {
|
||||||
|
"id": "CHHfbKhMacNo03V4",
|
||||||
|
"name": "MySQL account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": "บันทึก Error ลง MariaDB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "INSERT INTO migration_progress (batch_id, last_processed_index, status) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', {{$json.original_index || 0}}, 'RUNNING') ON DUPLICATE KEY UPDATE last_processed_index = {{$json.original_index || 0}}, updated_at = NOW()",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "bb0e611b-db28-4266-ba40-3b5d534a16f7",
|
||||||
|
"name": "Save Checkpoint",
|
||||||
|
"type": "n8n-nodes-base.mySql",
|
||||||
|
"typeVersion": 2.4,
|
||||||
|
"position": [34032, 13312],
|
||||||
|
"credentials": {
|
||||||
|
"mySql": {
|
||||||
|
"id": "CHHfbKhMacNo03V4",
|
||||||
|
"name": "MySQL account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": "บันทึก Checkpoint ทุก record ที่ผ่าน Review Queue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"amount": "={{$('Set Configuration').first().json.config.DELAY_MS / 1000}}",
|
||||||
|
"unit": "seconds"
|
||||||
|
},
|
||||||
|
"id": "07c1c5d5-5ffc-4e3d-ab3e-4b62ad079388",
|
||||||
|
"name": "Delay",
|
||||||
|
"type": "n8n-nodes-base.wait",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [34208, 13504],
|
||||||
|
"webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369",
|
||||||
|
"notes": "หน่วงเวลาระหว่าง Records"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {
|
||||||
|
"Form Trigger": {
|
||||||
|
"main": [[{ "node": "Set Configuration", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Set Configuration": {
|
||||||
|
"main": [[{ "node": "Check Backend Health", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Check Backend Health": {
|
||||||
|
"main": [[{ "node": "Validate Token", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Validate Token": {
|
||||||
|
"main": [[{ "node": "Fetch Categories", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Fetch Categories": {
|
||||||
|
"main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Fetch Tags": {
|
||||||
|
"main": [[{ "node": "File Mount Check", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"File Mount Check": {
|
||||||
|
"main": [[{ "node": "Read Excel Binary", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Read Excel Binary": {
|
||||||
|
"main": [[{ "node": "Read Excel", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Read Excel": {
|
||||||
|
"main": [[{ "node": "Read Checkpoint", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Read Checkpoint": {
|
||||||
|
"main": [[{ "node": "Process Batch + Encoding", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Process Batch + Encoding": {
|
||||||
|
"main": [[{ "node": "File Validator", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"File Validator": {
|
||||||
|
"main": [[{ "node": "Read PDF File", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Read PDF File": {
|
||||||
|
"main": [[{ "node": "Upload PDF to Backend", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Upload PDF to Backend": {
|
||||||
|
"main": [[{ "node": "Build AI Job Payload", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Build AI Job Payload": {
|
||||||
|
"main": [[{ "node": "Submit AI Job", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Submit AI Job": {
|
||||||
|
"main": [[{ "node": "Poll AI Job Status", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Poll AI Job Status": {
|
||||||
|
"main": [[{ "node": "Parse & Validate AI Response", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Parse & Validate AI Response": {
|
||||||
|
"main": [[{ "node": "Route by Confidence", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Route by Confidence": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Insert Review Queue (Auto)", "type": "main", "index": 0 }],
|
||||||
|
[{ "node": "Insert Review Queue (Flagged)", "type": "main", "index": 0 }],
|
||||||
|
[{ "node": "Log Reject to CSV", "type": "main", "index": 0 }],
|
||||||
|
[{ "node": "Log Error to CSV", "type": "main", "index": 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Insert Review Queue (Auto)": {
|
||||||
|
"main": [[{ "node": "Save Checkpoint", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Insert Review Queue (Flagged)": {
|
||||||
|
"main": [[{ "node": "Save Checkpoint", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Save Checkpoint": {
|
||||||
|
"main": [[{ "node": "Delay", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Log Reject to CSV": {
|
||||||
|
"main": [[{ "node": "Delay", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Log Error to CSV": {
|
||||||
|
"main": [[{ "node": "Log Error to DB", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Log Error to DB": {
|
||||||
|
"main": [[{ "node": "Delay", "type": "main", "index": 0 }]]
|
||||||
|
},
|
||||||
|
"Delay": {
|
||||||
|
"main": [[{ "node": "Read Checkpoint", "type": "main", "index": 0 }]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"availableInMCP": false
|
||||||
|
},
|
||||||
|
"versionId": "v2.0.0-adr023a-compliant",
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true,
|
||||||
|
"instanceId": "9e70e47c1eaf3bac72f497ddfbde0983f840f7d0f059537f7e37dd70de18ecb7"
|
||||||
|
},
|
||||||
|
"id": "u7CLP05AyFb8Um0P",
|
||||||
|
"tags": [
|
||||||
|
{ "name": "migration", "createdAt": "2026-05-23" },
|
||||||
|
{ "name": "v2", "createdAt": "2026-05-23" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user