260227:1640 20260227: add ollama #2
All checks were successful
Build and Deploy / deploy (push) Successful in 2m37s

This commit is contained in:
admin
2026-02-27 16:40:59 +07:00
parent 8a8a2e8659
commit 9ddafbb1ac
12 changed files with 15319 additions and 100 deletions

View File

@@ -0,0 +1,918 @@
# 📋 คู่มือการตั้งค่า n8n สำหรับ Legacy Data Migration
เอกสารนี้จัดทำขึ้นเพื่อรองรับการ Migration เอกสาร PDF 20,000 ฉบับ ตามแผนใน `03-04-legacy-data-migration.md` และ `ADR-017-ollama-data-migration.md`
> **Note:** Category Enum system-driven, Idempotency-Key Header, Storage Enforcement, Audit Log, Encoding Normalization, Security Hardening, Nginx Rate Limit, Docker Hardening, AI Physical Isolation (ASUSTOR), Folder Standard (/data/dms)
---
## 📌 ส่วนที่ 1: การติดตั้งและตั้งค่าเบื้องต้น
### 1.1 ติดตั้ง n8n บน ASUSTOR NAS (Docker)
```bash
mkdir -p /data/dms/n8n
cd /data/dms/n8n
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
n8n:
image: n8nio/n8n:latest
container_name: n8n-migration
restart: unless-stopped
# Docker Hardening (Patch)
mem_limit: 2g
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
ports:
- "5678:5678"
environment:
- N8N_HOST=0.0.0.0
- N8N_PORT=5678
- N8N_PROTOCOL=http
- NODE_ENV=production
- WEBHOOK_URL=http://<NAS_IP>:5678/
- GENERIC_TIMEZONE=Asia/Bangkok
- TZ=Asia/Bangkok
- N8N_SECURE_COOKIE=false
- N8N_USER_FOLDER=/home/node/.n8n
- N8N_PUBLIC_API_DISABLED=true
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=admin
- N8N_BASIC_AUTH_PASSWORD=<strong_password>
- N8N_PAYLOAD_SIZE_MAX=10485760
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=168
- EXECUTIONS_DATA_PRUNE_TIMEOUT=60
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=<DB_IP>
- DB_POSTGRESDB_PORT=5432
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=<password>
volumes:
- ./n8n_data:/home/node/.n8n
# read-only: อ่านไฟล์ PDF ต้นฉบับเท่านั้น
- /data/dms/staging_ai:/data/dms/staging_ai:ro
# read-write: เขียน Log และ CSV ทั้งหมด
- /data/dms/migration_logs:/data/dms/migration_logs:rw
networks:
- n8n-network
networks:
n8n-network:
driver: bridge
EOF
docker-compose up -d
```
> ⚠️ **Volume หมายเหตุ:** `/data/dms/staging_ai` = **read-only** (อ่านไฟล์ต้นฉบับ) และ `/data/dms/migration_logs` = **read-write** (เขียน Log/CSV) — ห้ามเขียน CSV ลง `staging_ai` เพราะจะ Error ทันที
### 1.2 Nginx Rate Limit
เพิ่มใน Nginx config สำหรับ Migration API:
```nginx
# nginx.conf หรือ site config
limit_req_zone $binary_remote_addr zone=migration:10m rate=1r/s;
location /api/correspondences/import {
limit_req zone=migration burst=5 nodelay;
proxy_pass http://backend:3001;
}
```
### 1.3 Environment Variables
**Settings → Environment Variables ใน n8n UI:**
| Variable | ค่าที่แนะนำ | คำอธิบาย |
| --------------------------- | ---------------------------- | ------------------------------------ |
| `OLLAMA_HOST` | `http://<ASUSTOR_IP>:11434` | URL ของ Ollama (ใน internal network) |
| `OLLAMA_MODEL_PRIMARY` | `llama3.2:3b` | Model หลัก |
| `OLLAMA_MODEL_FALLBACK` | `mistral:7b-instruct-q4_K_M` | Model สำรอง |
| `MIGRATION_BATCH_SIZE` | `10` | จำนวน Record ต่อ Batch |
| `MIGRATION_DELAY_MS` | `2000` | Delay ระหว่าง Request (ms) |
| `CONFIDENCE_THRESHOLD_HIGH` | `0.85` | Threshold Auto Ingest |
| `CONFIDENCE_THRESHOLD_LOW` | `0.60` | Threshold Review Queue |
| `MAX_RETRY_COUNT` | `3` | จำนวนครั้ง Retry |
| `FALLBACK_ERROR_THRESHOLD` | `5` | Error ที่ trigger Fallback |
| `BACKEND_URL` | `https://<BACKEND_URL>` | URL ของ LCBP3 Backend |
| `MIGRATION_BATCH_ID` | `migration_20260226` | ID ของ Batch |
---
## 📌 ส่วนที่ 2: การเตรียม Database
รัน SQL นี้บน MariaDB **ก่อน** เริ่ม n8n Workflow:
```sql
-- Checkpoint
CREATE TABLE IF NOT EXISTS migration_progress (
batch_id VARCHAR(50) PRIMARY KEY,
last_processed_index INT DEFAULT 0,
status ENUM('RUNNING','COMPLETED','FAILED') DEFAULT 'RUNNING',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Review Queue (Temporary — ไม่ใช่ Business Schema)
CREATE TABLE IF NOT EXISTS migration_review_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
document_number VARCHAR(100) NOT NULL,
title TEXT,
original_title TEXT,
ai_suggested_category VARCHAR(50),
ai_confidence DECIMAL(4,3),
ai_issues JSON,
review_reason VARCHAR(255),
status ENUM('PENDING','APPROVED','REJECTED') DEFAULT 'PENDING',
reviewed_by VARCHAR(100),
reviewed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_doc_number (document_number)
);
-- Error Log
CREATE TABLE IF NOT EXISTS migration_errors (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id VARCHAR(50),
document_number VARCHAR(100),
error_type ENUM('FILE_NOT_FOUND','AI_PARSE_ERROR','API_ERROR','DB_ERROR','UNKNOWN'),
error_message TEXT,
raw_ai_response TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_batch_id (batch_id),
INDEX idx_error_type (error_type)
);
-- Fallback State
CREATE TABLE IF NOT EXISTS migration_fallback_state (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id VARCHAR(50) UNIQUE,
recent_error_count INT DEFAULT 0,
is_fallback_active BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Idempotency (Patch)
CREATE TABLE IF NOT EXISTS import_transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
idempotency_key VARCHAR(255) UNIQUE NOT NULL,
document_number VARCHAR(100),
batch_id VARCHAR(100),
status_code INT DEFAULT 201,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_idem_key (idempotency_key)
);
-- Daily Summary
CREATE TABLE IF NOT EXISTS migration_daily_summary (
id INT AUTO_INCREMENT PRIMARY KEY,
batch_id VARCHAR(50),
summary_date DATE,
total_processed INT DEFAULT 0,
auto_ingested INT DEFAULT 0,
sent_to_review INT DEFAULT 0,
rejected INT DEFAULT 0,
errors INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_batch_date (batch_id, summary_date)
);
```
---
## 📌 ส่วนที่ 3: Credentials
**Credentials → Add New:**
#### 🔐 Ollama API
| Field | ค่า |
| -------------- | --------------------------- |
| Name | `Ollama Local API` |
| Type | `HTTP Request` |
| Base URL | `http://<ASUSTOR_IP>:11434` |
| Authentication | `None` |
#### 🔐 LCBP3 Backend API
| Field | ค่า |
| -------------- | --------------------------- |
| Name | `LCBP3 Migration Token` |
| Type | `HTTP Request` |
| Base URL | `https://<BACKEND_URL>/api` |
| Authentication | `Header Auth` |
| Header Name | `Authorization` |
| Header Value | `Bearer <MIGRATION_TOKEN>` |
#### 🔐 MariaDB
| Field | ค่า |
| -------- | ------------------ |
| Name | `LCBP3 MariaDB` |
| Type | `MariaDB` |
| Host | `<DB_IP>` |
| Port | `3306` |
| Database | `lcbp3_production` |
| User | `migration_bot` |
| Password | `<password>` |
---
## 📌 ส่วนที่ 4: Workflow (Step-by-Step)
### 4.1 โครงสร้างภาพรวม
```
┌──────────────────────────────────────────────────────────────────────┐
│ MIGRATION WORKFLOW v1.8.0 │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Node 0 │──▶│ Node 1 │──▶│ Node 2 │──▶│ Node 3 │ │
│ │Pre- │ │ Data │ │ File │ │ AI │ │
│ │flight + │ │ Reader │ │ Validat.│ │Analysis │ │
│ │Fetch Cat│ │+Encoding│ │+Sanitize│ │+Enum Chk│ │
│ └─────────┘ └─────────┘ └──┬──┬───┘ └────┬────┘ │
│ │ │ │ │
│ valid │ │ error ┌───▼──────────────┐ │
│ │ └──────▶ │ Node 3.5 │ │
│ │ │ Fallback Manager │ │
│ │ └──────────────────┘ │
│ ▼ │ │
│ ┌─────────┐ ┌────▼────┐ │
│ │ Node 5D │ │ Node 4 │ │
│ │ Error │ │Confidence│ │
│ │ Log │ │+Revision │ │
│ └─────────┘ │ Drift │ │
│ └┬──┬──┬──┘ │
│ │ │ │ │
│ ┌────────────┘ │ └──────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │ Node 5A │ │ Node 5B │ │ 5C │ │
│ │ Auto │ │ Review │ │Reject│ │
│ │ Ingest │ │ Queue │ │ Log │ │
│ │+Idempot. │ │(Temp only│ └──────┘ │
│ └────┬─────┘ └──────────┘ │
│ │ │
│ ┌────▼──────┐ │
│ │ Checkpoint│ │
│ └───────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
---
### 4.2 Node 0: Pre-flight + Fetch System Categories
Fetch System Categories ก่อน Batch ทุกครั้ง
**Sub-flow:**
```
[Trigger] → [HTTP: Ollama /api/tags] → [MariaDB: SELECT 1]
→ [HTTP: Backend /health] → [Code: File Mount Check]
→ [HTTP: GET /api/meta/categories] → [Store in Workflow Variable]
→ [IF all pass → Node 1] [ELSE → Stop + Alert]
```
**HTTP Node — Fetch Categories:**
```json
{
"method": "GET",
"url": "={{ $env.BACKEND_URL }}/api/meta/categories",
"authentication": "genericCredentialType",
"genericAuthType": "lcbp3MigrationToken"
}
```
**Code Node — Store Categories + File Mount Check:**
```javascript
const fs = require('fs');
// เก็บ categories ใน Workflow Variable
const categories = $input.first().json.categories;
if (!categories || !Array.isArray(categories) || categories.length === 0) {
throw new Error('Failed to fetch system categories from backend');
}
// Set Workflow Variable เพื่อใช้ใน Node 3
$workflow.variables = $workflow.variables || {};
$workflow.variables.system_categories = categories;
// ตรวจ File Mount
try {
const files = fs.readdirSync('/data/dms/staging_ai');
if (files.length === 0) throw new Error('staging_ai is empty');
fs.writeFileSync('/data/dms/migration_logs/.preflight_ok', new Date().toISOString());
} catch (err) {
throw new Error(`File mount check failed: ${err.message}`);
}
return [{ json: { preflight_ok: true, system_categories: categories } }];
```
---
### 4.3 Node 1: Load Checkpoint + Read Excel + Encoding Normalization
**Step 1 — MariaDB Node (Read Checkpoint):**
```sql
SELECT last_processed_index, status
FROM migration_progress
WHERE batch_id = '{{ $env.MIGRATION_BATCH_ID }}'
LIMIT 1;
```
**Step 2 — Spreadsheet File Node:**
```json
{ "operation": "toData", "binaryProperty": "data", "options": { "sheetName": "Sheet1" } }
```
**Step 3 — Code Node (Checkpoint + Batch + Encoding):**
```javascript
const checkpointResult = $('Read Checkpoint').first();
let startIndex = 0;
if (checkpointResult && checkpointResult.json.status === 'RUNNING') {
startIndex = checkpointResult.json.last_processed_index || 0;
}
const allItems = $('Read Excel').all();
const remaining = allItems.slice(startIndex);
const batchSize = parseInt($env.MIGRATION_BATCH_SIZE) || 10;
const currentBatch = remaining.slice(0, batchSize);
// Encoding Normalization: Excel → UTF-8 NFC (Patch)
const normalize = (str) => {
if (!str) return '';
return Buffer.from(String(str), 'utf8').toString('utf8').normalize('NFC');
};
return currentBatch.map((item, i) => ({
...item,
json: {
...item.json,
document_number: normalize(item.json.document_number),
title: normalize(item.json.title),
original_index: startIndex + i
}
}));
```
---
### 4.4 Node 2: File Validator & Sanitizer
**Node Type:** `Code`**2 Outputs**
```javascript
const fs = require('fs');
const path = require('path');
const items = $input.all();
const validatedItems = [];
const errorItems = [];
for (const item of items) {
const docNumber = item.json.document_number;
// Sanitize + Normalize Filename (Patch)
const safeName = path.basename(
String(docNumber).replace(/[^a-zA-Z0-9\-_.]/g, '_')
).normalize('NFC');
const filePath = path.resolve('/data/dms/staging_ai', `${safeName}.pdf`);
if (!filePath.startsWith('/data/dms/staging_ai/')) {
errorItems.push({ ...item, json: { ...item.json, error: 'Path traversal detected', error_type: 'FILE_NOT_FOUND' } });
continue;
}
try {
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath);
validatedItems.push({ ...item, json: { ...item.json, file_exists: true, file_size: stats.size, file_path: filePath } });
} else {
errorItems.push({ ...item, json: { ...item.json, error: `File not found: ${filePath}`, error_type: 'FILE_NOT_FOUND', file_exists: false } });
}
} catch (err) {
errorItems.push({ ...item, json: { ...item.json, error: err.message, error_type: 'FILE_NOT_FOUND', file_exists: false } });
}
}
// Output 0 → Node 3 | Output 1 → Node 5D
return [validatedItems, errorItems];
```
---
### 4.5 Node 3: Build Prompt + AI Analysis
**Step 1 — MariaDB (Read Fallback State):**
```sql
SELECT is_fallback_active FROM migration_fallback_state
WHERE batch_id = '{{ $env.MIGRATION_BATCH_ID }}' LIMIT 1;
```
**Step 2 — Code Node (Build Prompt: inject system_categories):**
```javascript
const fallbackState = $('Read Fallback State').first();
const isFallback = fallbackState?.json?.is_fallback_active || false;
const model = isFallback ? $env.OLLAMA_MODEL_FALLBACK : $env.OLLAMA_MODEL_PRIMARY;
// ใช้ system_categories จาก Workflow Variable (ไม่ hardcode)
const systemCategories = $workflow.variables?.system_categories
|| ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];
const item = $input.first();
const systemPrompt = `You are a Document Controller for a large construction project.
Your task is to validate document metadata.
You MUST respond ONLY with valid JSON. No explanation, no markdown, no extra text.
If there are no issues, "detected_issues" must be an empty array [].`;
const userPrompt = `Validate this document metadata and respond in JSON:
Document Number: ${item.json.document_number}
Title: ${item.json.title}
Expected Pattern: [ORG]-[TYPE]-[SEQ] e.g. "TCC-COR-0001"
Category List (MUST match system enum exactly): ${JSON.stringify(systemCategories)}
Respond ONLY with this exact JSON structure:
{
"is_valid": true | false,
"confidence": 0.0 to 1.0,
"suggested_category": "<one from Category List>",
"detected_issues": ["<issue1>"],
"suggested_title": "<corrected title or null>"
}`;
return [{
json: {
...item.json,
active_model: model,
system_categories: systemCategories,
ollama_payload: { model, prompt: `${systemPrompt}\n\n${userPrompt}`, stream: false, format: 'json' }
}
}];
```
**Step 3 — HTTP Request Node (Ollama):**
```json
{
"method": "POST",
"url": "={{ $env.OLLAMA_HOST }}/api/generate",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.ollama_payload }}",
"options": { "timeout": 30000, "retry": { "count": 3, "delay": 2000, "backoff": "exponential" } }
}
```
**Step 4 — Code Node (Parse + Validate: Enum check):**
```javascript
const items = $input.all();
const parsed = [];
const parseErrors = [];
for (const item of items) {
try {
let raw = item.json.response || '';
raw = raw.replace(/```json/gi, '').replace(/```/g, '').trim();
const result = JSON.parse(raw);
// Strict Schema Validation
if (typeof result.is_valid !== 'boolean')
throw new Error('is_valid must be boolean');
if (typeof result.confidence !== 'number' || result.confidence < 0 || result.confidence > 1)
throw new Error('confidence must be float 0.01.0');
if (!Array.isArray(result.detected_issues))
throw new Error('detected_issues must be array');
// Enum Validation ตรง System Categories (Patch)
const systemCategories = item.json.system_categories || [];
if (!systemCategories.includes(result.suggested_category))
throw new Error(`Category "${result.suggested_category}" not in system enum: [${systemCategories.join(', ')}]`);
parsed.push({ ...item, json: { ...item.json, ai_result: result, parse_error: null } });
} catch (err) {
parseErrors.push({
...item,
json: { ...item.json, ai_result: null, parse_error: err.message, raw_ai_response: item.json.response, error_type: 'AI_PARSE_ERROR' }
});
}
}
// Output 0 → Node 4 | Output 1 → Node 3.5 + Node 5D
return [parsed, parseErrors];
```
---
### 4.6 Node 3.5: Fallback Model Manager
**MariaDB Node:**
```sql
INSERT INTO migration_fallback_state (batch_id, recent_error_count, is_fallback_active)
VALUES ('{{ $env.MIGRATION_BATCH_ID }}', 1, FALSE)
ON DUPLICATE KEY UPDATE
recent_error_count = recent_error_count + 1,
is_fallback_active = CASE
WHEN recent_error_count + 1 >= {{ $env.FALLBACK_ERROR_THRESHOLD }} THEN TRUE
ELSE is_fallback_active
END,
updated_at = NOW();
```
**Code Node (Alert):**
```javascript
const state = $input.first().json;
if (state.is_fallback_active) {
return [{ json: {
...state, alert: true,
alert_message: `⚠️ Fallback model (${$env.OLLAMA_MODEL_FALLBACK}) activated after ${state.recent_error_count} errors`
}}];
}
return [{ json: { ...state, alert: false } }];
```
---
### 4.7 Node 4: Confidence Router + Revision Drift Protection
**Node Type:** `Code`**4 Outputs**
```javascript
const items = $input.all();
const autoIngest = [];
const reviewQueue = [];
const rejectLog = [];
const errorLog = [];
const HIGH = parseFloat($env.CONFIDENCE_THRESHOLD_HIGH) || 0.85;
const LOW = parseFloat($env.CONFIDENCE_THRESHOLD_LOW) || 0.60;
for (const item of items) {
if (item.json.parse_error || !item.json.ai_result) {
errorLog.push(item); continue;
}
// Revision Drift Protection
if (item.json.excel_revision !== undefined) {
const expectedRev = (item.json.current_db_revision || 0) + 1;
if (parseInt(item.json.excel_revision) !== expectedRev) {
reviewQueue.push({
...item,
json: { ...item.json, review_reason: `Revision drift: Excel=${item.json.excel_revision}, Expected=${expectedRev}` }
});
continue;
}
}
const ai = item.json.ai_result;
if (ai.confidence >= HIGH && ai.is_valid === true) {
autoIngest.push(item);
} else if (ai.confidence >= LOW) {
reviewQueue.push({ ...item, json: { ...item.json, review_reason: `Confidence ${ai.confidence.toFixed(2)} < ${HIGH}` } });
} else {
rejectLog.push({
...item,
json: { ...item.json, reject_reason: ai.is_valid === false ? 'AI marked invalid' : `Confidence ${ai.confidence.toFixed(2)} < ${LOW}` }
});
}
}
// Output 0: Auto Ingest | 1: Review Queue | 2: Reject Log | 3: Error Log
return [autoIngest, reviewQueue, rejectLog, errorLog];
```
---
### 4.8 Node 5A: Auto Ingest + Idempotency + Checkpoint
**HTTP Request Node (Patch — Idempotency-Key Header + source_file_path):**
```json
{
"method": "POST",
"url": "={{ $env.BACKEND_URL }}/api/correspondences/import",
"authentication": "genericCredentialType",
"genericAuthType": "lcbp3MigrationToken",
"sendHeaders": true,
"headers": {
"Idempotency-Key": "={{ $json.document_number }}:{{ $env.MIGRATION_BATCH_ID }}"
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": {
"document_number": "={{ $json.document_number }}",
"title": "={{ $json.ai_result.suggested_title || $json.title }}",
"category": "={{ $json.ai_result.suggested_category }}",
"source_file_path": "={{ $json.file_path }}",
"ai_confidence": "={{ $json.ai_result.confidence }}",
"ai_issues": "={{ $json.ai_result.detected_issues }}",
"migrated_by": "SYSTEM_IMPORT",
"batch_id": "={{ $env.MIGRATION_BATCH_ID }}"
},
"options": { "timeout": 30000, "retry": { "count": 3, "delay": 5000 } }
}
```
> Backend จะ generate UUID, enforce Storage path `/storage/{project}/{category}/{year}/{month}/{uuid}.pdf`, move file ผ่าน StorageService และบันทึก Audit Log `action=IMPORT, source=MIGRATION`
**Checkpoint Code Node (ทุก 10 Records):**
```javascript
const item = $input.first();
return [{ json: {
...item.json,
should_update_checkpoint: item.json.original_index % 10 === 0,
checkpoint_index: item.json.original_index
}}];
```
**IF Node → MariaDB Checkpoint:**
```sql
INSERT INTO migration_progress (batch_id, last_processed_index, status)
VALUES ('{{ $env.MIGRATION_BATCH_ID }}', {{ $json.checkpoint_index }}, 'RUNNING')
ON DUPLICATE KEY UPDATE last_processed_index = {{ $json.checkpoint_index }}, updated_at = NOW();
```
---
### 4.9 Node 5B: Review Queue (Temporary Table)
> ⚠️ ห้ามสร้าง Correspondence record — รอ Admin Approve แล้วค่อย POST `/api/correspondences/import`
```sql
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.suggested_title || $json.title }}',
'{{ $json.title }}',
'{{ $json.ai_result.suggested_category }}',
{{ $json.ai_result.confidence }},
'{{ JSON.stringify($json.ai_result.detected_issues) }}',
'{{ $json.review_reason }}',
'PENDING', NOW()
)
ON DUPLICATE KEY UPDATE status = 'PENDING', review_reason = '{{ $json.review_reason }}';
```
---
### 4.10 Node 5C: Reject Log → `/data/migration_logs/`
```javascript
const fs = require('fs');
const item = $input.first();
const csvPath = '/data/dms/migration_logs/reject_log.csv';
const header = 'timestamp,document_number,title,reject_reason,ai_confidence,ai_issues\n';
const esc = (s) => `"${String(s||'').replace(/"/g,'""')}"`;
if (!fs.existsSync(csvPath)) fs.writeFileSync(csvPath, header, 'utf8');
const line = [
new Date().toISOString(),
esc(item.json.document_number), esc(item.json.title),
esc(item.json.reject_reason),
item.json.ai_result?.confidence ?? 'N/A',
esc(JSON.stringify(item.json.ai_result?.detected_issues || []))
].join(',') + '\n';
fs.appendFileSync(csvPath, line, 'utf8');
return [$input.first()];
```
---
### 4.11 Node 5D: Error Log → `/data/migration_logs/` + MariaDB
```javascript
const fs = require('fs');
const item = $input.first();
const csvPath = '/data/dms/migration_logs/error_log.csv';
const header = 'timestamp,document_number,error_type,error_message,raw_ai_response\n';
const esc = (s) => `"${String(s||'').replace(/"/g,'""')}"`;
if (!fs.existsSync(csvPath)) fs.writeFileSync(csvPath, header, 'utf8');
const line = [
new Date().toISOString(),
esc(item.json.document_number),
esc(item.json.error_type || 'UNKNOWN'),
esc(item.json.error || item.json.parse_error),
esc(item.json.raw_ai_response || '')
].join(',') + '\n';
fs.appendFileSync(csvPath, line, 'utf8');
return [$input.first()];
```
**MariaDB Node:**
```sql
INSERT INTO migration_errors
(batch_id, document_number, error_type, error_message, raw_ai_response, created_at)
VALUES (
'{{ $env.MIGRATION_BATCH_ID }}', '{{ $json.document_number }}',
'{{ $json.error_type || "UNKNOWN" }}', '{{ $json.error || $json.parse_error }}',
'{{ $json.raw_ai_response || "" }}', NOW()
);
```
---
## 📌 ส่วนที่ 5: Rollback Workflow
**Workflow: `Migration Rollback`** — Manual Trigger เท่านั้น
```
[Manual Trigger: {confirmation: "CONFIRM_ROLLBACK"}]
[Code: Guard — ต้องพิมพ์ "CONFIRM_ROLLBACK"]
│ PASS
[MariaDB: Disable Token]
UPDATE users SET is_active = false WHERE username = 'migration_bot';
[MariaDB: Delete File Records]
DELETE FROM correspondence_files WHERE correspondence_id IN
(SELECT id FROM correspondences WHERE created_by = 'SYSTEM_IMPORT');
[MariaDB: Delete Correspondence Records]
DELETE FROM correspondences WHERE created_by = 'SYSTEM_IMPORT';
[MariaDB: Clear Idempotency Records]
DELETE FROM import_transactions WHERE batch_id = '{{$env.MIGRATION_BATCH_ID}}';
[MariaDB: Reset Checkpoint + Fallback State]
[Email: Rollback Report → Admin]
```
**Confirmation Guard:**
```javascript
if ($input.first().json.confirmation !== 'CONFIRM_ROLLBACK') {
throw new Error('Rollback cancelled: type "CONFIRM_ROLLBACK" to proceed.');
}
return $input.all();
```
---
## 📌 ส่วนที่ 6: End-of-Night Summary (06:30 ทุกวัน)
**MariaDB:**
```sql
SELECT
mp.last_processed_index AS total_progress,
(SELECT COUNT(*) FROM correspondences
WHERE created_by = 'SYSTEM_IMPORT' AND DATE(created_at) = CURDATE()) AS auto_ingested,
(SELECT COUNT(*) FROM migration_review_queue WHERE DATE(created_at) = CURDATE()) AS sent_to_review,
(SELECT COUNT(*) FROM migration_errors
WHERE batch_id = '{{ $env.MIGRATION_BATCH_ID }}' AND DATE(created_at) = CURDATE()) AS errors
FROM migration_progress mp WHERE mp.batch_id = '{{ $env.MIGRATION_BATCH_ID }}';
```
**Code Node (Build Report):**
```javascript
const s = $input.first().json;
const total = 20000;
const pct = ((s.total_progress / total) * 100).toFixed(1);
const nightsLeft = Math.ceil((total - s.total_progress) / (8 * 3600 / 3));
const report = `
📊 Migration Night Summary — ${new Date().toLocaleDateString('th-TH')}
${'─'.repeat(50)}
✅ Auto Ingested : ${s.auto_ingested}
🔍 Sent to Review : ${s.sent_to_review}
❌ Errors : ${s.errors}
─────────────────────────────────────────────────
📈 Progress : ${s.total_progress} / ${total} (${pct}%)
🌙 Est. Nights Left: ~${nightsLeft} คืน
${'─'.repeat(50)}
${s.errors > 50 ? '⚠️ WARNING: High error count — investigate before next run' : '✅ Error rate OK'}
`;
return [{ json: { report, stats: s } }];
```
---
## 📌 ส่วนที่ 7: Monitoring (Hourly Alert — เฉพาะเมื่อเกิน Threshold)
**Code Node (Evaluate):**
```javascript
const s = $input.first().json;
const alerts = [];
if (s.minutes_since_update > 30)
alerts.push(`⚠️ No progress for ${s.minutes_since_update} min — may be stuck`);
if (s.is_fallback_active)
alerts.push(`⚠️ Fallback model active — errors: ${s.recent_error_count}`);
if (s.recent_error_count >= 20)
alerts.push(`🔴 Critical: ${s.recent_error_count} errors — consider stopping`);
return [{ json: { ...s, has_alerts: alerts.length > 0, alerts } }];
```
**IF `has_alerts = true` → Email Alert ทันที**
---
## 📌 ส่วนที่ 8: Pre-Production Checklist
| ลำดับ | รายการทดสอบ | ผลลัพธ์ที่คาดหวัง | ✅/❌ |
| --- | --------------------------------------------- | ---------------------- | --- |
| 1 | Pre-flight ผ่านทุก Check | All green | |
| 2 | `GET /api/meta/categories` สำเร็จ | categories array ไม่ว่าง | |
| 3 | Enum ใน Prompt ไม่ hardcode | ตรงกับ Backend | |
| 4 | Idempotency: รัน Batch ซ้ำ | ไม่สร้าง Revision ซ้ำ | |
| 5 | Storage path ตาม Spec | UUID + /year/month/ | |
| 6 | Audit Log มี `action=IMPORT, source=MIGRATION` | Verified | |
| 7 | Review Queue ไม่สร้าง record อัตโนมัติ | Verified | |
| 8 | Revision drift → Review Queue | Verified | |
| 9 | Error ≥ 5 → Fallback Model สลับ | mistral:7b active | |
| 10 | Reject/Error CSV เขียนลง `migration_logs/` | ไม่ใช่ `staging_ai/` | |
| 11 | Rollback Guard ต้องพิมพ์ CONFIRM_ROLLBACK | Block ทำงาน | |
| 12 | Night Summary 06:30 + Est. nights left | Email ถึง Admin | |
| 13 | Monitoring Alert เฉพาะเกิน Threshold | ไม่ spam ทุกชั่วโมง | |
| 14 | Nginx Rate Limit `burst=5` | Configured | |
| 15 | Docker `mem_limit=2g` + log rotation | Configured | |
**คำสั่งทดสอบ:**
```bash
# Ollama
docker exec -it n8n-migration curl http://<ASUSTOR_IP>:11434/api/tags
# RO mount
docker exec -it n8n-migration ls /data/dms/staging_ai | head -5
# RW mount
docker exec -it n8n-migration sh -c "echo ok > /data/dms/migration_logs/test.txt && echo '✅ rw OK'"
# DB
docker exec -it n8n-migration mysql -h <DB_IP> -u migration_bot -p -e "SELECT 1"
# Backend + Category endpoint
curl -H "Authorization: Bearer <TOKEN>" https://<BACKEND>/api/meta/categories
```
---
## 📌 ส่วนที่ 9: การรันงานจริง
### 9.1 Daily Operation
| เวลา | กิจกรรม | ผู้รับผิดชอบ |
| ----- | ------------------------------ | ------------------- |
| 08:00 | ตรวจสอบ Night Summary Email | Admin |
| 09:00 | Approve/Reject ใน Review Queue | Document Controller |
| 17:00 | ตรวจ Disk Space + GPU Temp | DevOps |
| 22:00 | Workflow เริ่มรันอัตโนมัติ | System |
| 06:30 | Night Summary Report ส่ง Email | System |
### 9.2 Emergency Stop
```bash
# 1. หยุด n8n
docker stop n8n-migration
# 2. Disable Token
mysql -h <DB_IP> -u root -p \
-e "UPDATE users SET is_active = false WHERE username = 'migration_bot';"
# 3. Progress
mysql -h <DB_IP> -u root -p \
-e "SELECT * FROM migration_progress WHERE batch_id = 'migration_20260226';"
# 4. Errors
mysql -h <DB_IP> -u root -p \
-e "SELECT * FROM migration_errors ORDER BY created_at DESC LIMIT 20;"
# 5. Rollback ผ่าน Webhook
curl -X POST http://<NAS_IP>:5678/webhook/rollback \
-H "Content-Type: application/json" \
-d '{"confirmation":"CONFIRM_ROLLBACK"}'
```
---
## 📞 การติดต่อสนับสนุน
| ปัญหา | ช่องทางติดต่อ |
| --------------- | ------------------------------------------- |
| Technical Issue | DevOps Team (Slack: #migration-support) |
| Data Issue | Document Controller (Email: dc@lcbp3.local) |
| Security Issue | Security Team (Email: security@lcbp3.local) |
---
**เอกสารฉบับนี้จัดทำขึ้นเพื่อรองรับ Migration ตาม ADR-017 และ 03-04**
**Version:** 1.8.0 | **Last Updated:** 2026-02-27 | **Author:** Development Team