This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
|
||||
export class EnqueueMigrationDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
document_number!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
original_title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
category?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
ai_summary?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
project_id?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
sender_org_id?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
receiver_org_id?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
issued_date?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
received_date?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
remarks?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
extracted_tags?: any[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
temp_attachment_id?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_valid?: boolean;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
confidence?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
ai_issues?: any[];
|
||||
}
|
||||
@@ -20,8 +20,12 @@ export class ImportCorrespondenceDto {
|
||||
category!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
source_file_path!: string;
|
||||
@IsOptional()
|
||||
source_file_path?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
temp_attachment_id?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
|
||||
@@ -56,6 +56,33 @@ export class MigrationReviewQueue {
|
||||
@Column({ name: 'reviewed_at', type: 'timestamp', nullable: true })
|
||||
reviewedAt?: Date;
|
||||
|
||||
@Column({ name: 'project_id', type: 'int', nullable: true })
|
||||
projectId?: number;
|
||||
|
||||
@Column({ name: 'sender_organization_id', type: 'int', nullable: true })
|
||||
senderOrganizationId?: number;
|
||||
|
||||
@Column({ name: 'receiver_organization_id', type: 'int', nullable: true })
|
||||
receiverOrganizationId?: number;
|
||||
|
||||
@Column({ name: 'received_date', type: 'date', nullable: true })
|
||||
receivedDate?: Date;
|
||||
|
||||
@Column({ name: 'issued_date', type: 'date', nullable: true })
|
||||
issuedDate?: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
remarks?: string;
|
||||
|
||||
@Column({ name: 'ai_summary', type: 'text', nullable: true })
|
||||
aiSummary?: string;
|
||||
|
||||
@Column({ name: 'extracted_tags', type: 'json', nullable: true })
|
||||
extractedTags?: any;
|
||||
|
||||
@Column({ name: 'temp_attachment_id', type: 'int', nullable: true })
|
||||
tempAttachmentId?: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Post, Body, Headers, UseGuards, Get, Param, Query, Res, ParseIntPipe } from '@nestjs/common';
|
||||
import { MigrationService } from './migration.service';
|
||||
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||
import { EnqueueMigrationDto } from './dto/enqueue-migration.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiHeader, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||
@@ -30,6 +31,13 @@ export class MigrationController {
|
||||
return this.migrationService.importCorrespondence(dto, idempotencyKey, userId);
|
||||
}
|
||||
|
||||
@Post('queue')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Enqueue a record into the staging migration review queue' })
|
||||
async enqueueRecord(@Body() dto: EnqueueMigrationDto) {
|
||||
return this.migrationService.enqueueRecord(dto);
|
||||
}
|
||||
|
||||
@Get('queue')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Get migration review queue' })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
|
||||
import { EnqueueMigrationDto } from './dto/enqueue-migration.dto';
|
||||
import { ImportTransaction } from './entities/import-transaction.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from './entities/migration-review-queue.entity';
|
||||
import { MigrationError } from './entities/migration-error.entity';
|
||||
import { MigrationQueueQueryDto } from './dto/migration-queue-query.dto';
|
||||
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
@Injectable()
|
||||
@@ -177,7 +179,20 @@ export class MigrationService {
|
||||
|
||||
// 4. File Handling
|
||||
let attachmentId: number | null = null;
|
||||
if (dto.source_file_path) {
|
||||
if (dto.temp_attachment_id) {
|
||||
attachmentId = dto.temp_attachment_id;
|
||||
try {
|
||||
// Mark attachment as permanent
|
||||
await queryRunner.manager.update(
|
||||
Attachment,
|
||||
{ id: attachmentId },
|
||||
{ isTemporary: false }
|
||||
);
|
||||
} catch (fileError: unknown) {
|
||||
const errMsg = fileError instanceof Error ? fileError.message : String(fileError);
|
||||
this.logger.warn(`Failed to update temp_file [id:${attachmentId}]: ${errMsg}`);
|
||||
}
|
||||
} else if (dto.source_file_path) {
|
||||
try {
|
||||
const attachment = await this.fileStorageService.importStagingFile(
|
||||
dto.source_file_path,
|
||||
@@ -360,6 +375,63 @@ export class MigrationService {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
async enqueueRecord(dto: EnqueueMigrationDto) {
|
||||
if (!dto.document_number) {
|
||||
throw new BadRequestException('document_number is required');
|
||||
}
|
||||
|
||||
// Determine status based on confidence policy in ADR-017
|
||||
let autoStatus = MigrationReviewStatus.PENDING;
|
||||
if (dto.is_valid === false || (dto.confidence != null && dto.confidence < 0.60)) {
|
||||
autoStatus = MigrationReviewStatus.REJECTED;
|
||||
}
|
||||
|
||||
// Upsert or create new queue item
|
||||
let queueItem = await this.reviewQueueRepo.findOne({
|
||||
where: { documentNumber: dto.document_number },
|
||||
});
|
||||
|
||||
if (!queueItem) {
|
||||
queueItem = this.reviewQueueRepo.create({
|
||||
documentNumber: dto.document_number,
|
||||
});
|
||||
}
|
||||
|
||||
queueItem.title = dto.title;
|
||||
queueItem.originalTitle = dto.original_title;
|
||||
queueItem.aiSuggestedCategory = dto.category;
|
||||
queueItem.aiConfidence = dto.confidence;
|
||||
queueItem.aiIssues = dto.ai_issues;
|
||||
queueItem.projectId = dto.project_id;
|
||||
queueItem.senderOrganizationId = dto.sender_org_id;
|
||||
queueItem.receiverOrganizationId = dto.receiver_org_id;
|
||||
queueItem.remarks = dto.remarks;
|
||||
queueItem.aiSummary = dto.ai_summary;
|
||||
queueItem.extractedTags = dto.extracted_tags;
|
||||
queueItem.tempAttachmentId = dto.temp_attachment_id;
|
||||
queueItem.status = autoStatus;
|
||||
|
||||
if (dto.issued_date) {
|
||||
const parsed = new Date(dto.issued_date);
|
||||
if (!isNaN(parsed.getTime())) queueItem.issuedDate = parsed;
|
||||
}
|
||||
if (dto.received_date) {
|
||||
const parsed = new Date(dto.received_date);
|
||||
if (!isNaN(parsed.getTime())) queueItem.receivedDate = parsed;
|
||||
}
|
||||
|
||||
await this.reviewQueueRepo.save(queueItem);
|
||||
|
||||
this.logger.log(`Enqueued document [${dto.document_number}] to staging queue with status [${autoStatus}]`);
|
||||
|
||||
return {
|
||||
message: 'Document enqueued successfully',
|
||||
id: queueItem.id,
|
||||
status: autoStatus,
|
||||
};
|
||||
}
|
||||
|
||||
async getReviewQueue(query: MigrationQueueQueryDto) {
|
||||
const { page = 1, limit = 10, status } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
@@ -206,206 +206,102 @@ n8n ต้องเก็บ categories นี้ไว้ใน Workflow Variab
|
||||
|
||||
#### Node 1: Data Reader & Checkpoint
|
||||
|
||||
- อ่าน Checkpoint จาก **MariaDB Node แยก** (ไม่ใช่ async call ใน Code Node)
|
||||
- Batch ทีละ **10–20 แถว** ตาม `$env.MIGRATION_BATCH_SIZE`
|
||||
- ติด `original_index` ทุก Item
|
||||
#### Node 1: Data Reader & Checkpoint
|
||||
|
||||
**Encoding Normalization:**
|
||||
```javascript
|
||||
// Normalize ข้อมูลจาก Excel เป็น UTF-8 NFC ก่อนประมวลผล
|
||||
const normalize = (str) => {
|
||||
if (!str) return '';
|
||||
return Buffer.from(str, 'utf8').toString('utf8').normalize('NFC');
|
||||
};
|
||||
- อ่าน Checkpoint จาก **MariaDB Node แยก**
|
||||
- Batch ทีละ **50–100 แถว** ตาม `$env.MIGRATION_BATCH_SIZE` (ควรจำกัด Batch Size ป้องกัน DB Connection Overload)
|
||||
- ติด `original_index` ทุก Item และ Normalize Encoding (UTF-8 NFC) สำหรับ ชื่อไฟล์ และ เลขเอกสารเก่า
|
||||
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
json: {
|
||||
...item.json,
|
||||
document_number: normalize(item.json.document_number),
|
||||
title: normalize(item.json.title),
|
||||
// Mapping เลขอ้างอิงเก่า (Legacy Number) เพื่อนำไปเก็บใน details JSON
|
||||
legacy_document_number: item.json.document_number
|
||||
}
|
||||
}));
|
||||
```
|
||||
#### Node 2: DB Lookup & Data Augmentation
|
||||
|
||||
#### Node 2: File Validator & Sanitizer
|
||||
- **Task:** ให้ n8n นำข้อมูลจาก Excel (เช่น รหัสโปรเจ็กต์, รหัสผู้ส่ง) ยิงคำสั่ง Query ไปยัง MariaDB เพื่อแปลงเป็น `id`
|
||||
- **Queries:**
|
||||
1. แปลง `project_code` -> `project_id`
|
||||
2. แปลง `sender_code` -> `sender_organization_id`
|
||||
3. แปลง `receiver_code` -> `receiver_organization_id`
|
||||
4. หา Tags ที่มีอยู่ในโปรเจ็กต์: `SELECT * FROM tags WHERE project_id = {{project_id}}`
|
||||
- **Output:** n8n เก็บ `project_id`, `organization_ids` และ `existing_tags_json` ไว้ในแต่ละ item
|
||||
- *ถ้าหารหัสโปรเจ็กต์ไม่เจอ ให้ส่งเข้า Error Log ไม่ทำต่อ*
|
||||
|
||||
- ตรวจสอบไฟล์ PDF มีอยู่จริงบน NAS
|
||||
- Normalize ชื่อไฟล์เป็น **UTF-8 NFC**
|
||||
- Path Traversal Guard: resolved path ต้องอยู่ใน `/share/np-dms/staging_ai` เท่านั้น
|
||||
- **Output 0** → valid → Node 3
|
||||
- **Output 1** → error → Node 5D (ไม่หายเงียบ)
|
||||
#### Node 3: File Processor (Extract PDF Text & Temp Upload)
|
||||
|
||||
#### Node 3: AI Analysis (Sequential เท่านั้น)
|
||||
- ตรวจสอบไฟล์ PDF มีอยู่จริงบน NAS `/share/np-dms/staging_ai`
|
||||
- **Extract PDF Text:** ใช้ Apache Tika สกัดข้อความจากเอกสาร
|
||||
- **Two-Phase Storage (Upload):**
|
||||
- n8n ยิง `POST /api/storage/upload` ส่งไฟล์ PDF เข้า Backend
|
||||
- Backend อัพโหลดไฟล์, กำหนด `is_temporary = TRUE`
|
||||
- Backend ส่งคืน `attachment_id` ให้ n8n (จะเรียกว่า `temp_attachment_id`)
|
||||
|
||||
#### Node 4: AI Analysis (Sequential เท่านั้น)
|
||||
|
||||
**System Prompt:**
|
||||
```text
|
||||
You are a Document Controller for a large construction project.
|
||||
Your task is to validate document metadata and suggest relevant tags.
|
||||
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 [].
|
||||
Your task is to validate document metadata, summarize content, and suggest relevant tags.
|
||||
You MUST respond ONLY with valid JSON. No explanation, no markdown.
|
||||
```
|
||||
|
||||
**User Prompt (Category List มาจาก Backend ไม่ hardcode):**
|
||||
**User Prompt:**
|
||||
```text
|
||||
Validate this document metadata and respond in JSON:
|
||||
|
||||
Validate and summarize this document. Respond in JSON.
|
||||
Document Number: {{$json.document_number}}
|
||||
Title: {{$json.title}}
|
||||
Expected Pattern: [ORG]-[TYPE]-[SEQ] e.g. "TCC-COR-0001"
|
||||
Category List (MUST match system enum exactly): {{$workflow.variables.system_categories}}
|
||||
Extracted Text: {{$json.extracted_text}}
|
||||
|
||||
Analyze the document and suggest relevant tags based on:
|
||||
1. Document content/title keywords (e.g., "Foundation", "Structure", "Electrical", "Safety")
|
||||
2. Document type indicators (e.g., "Drawing", "Report", "Inspection")
|
||||
3. Organization codes present in document number
|
||||
4. Any discipline or phase indicators
|
||||
Existing Project Tags: {{$json.existing_tags_json}}
|
||||
|
||||
Analyze the content to provide:
|
||||
1. Validation of Subject/Dates with PDF text.
|
||||
2. A 4-5 sentence summary.
|
||||
3. Suggest tags. Select from Existing Project Tags if applicable. If no existing tag fits, suggest a NEW one (set is_new: true).
|
||||
|
||||
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>",
|
||||
"suggested_tags": ["<tag1>", "<tag2>"],
|
||||
"tag_confidence": 0.0 to 1.0
|
||||
"category": "Correspondence",
|
||||
"summary": "<4-5 sentence summary>",
|
||||
"suggested_tags": [
|
||||
{"name": "Structural", "description": "...", "is_new": false}
|
||||
],
|
||||
"detected_issues": []
|
||||
}
|
||||
```
|
||||
|
||||
**JSON Validation (ตรวจ Category ตรง Enum + Tag Normalization):**
|
||||
```javascript
|
||||
const systemCategories = $workflow.variables.system_categories;
|
||||
if (!systemCategories.includes(result.suggested_category)) {
|
||||
throw new Error(`Category "${result.suggested_category}" not in system enum: ${systemCategories.join(', ')}`);
|
||||
}
|
||||
#### Node 5: Staging Ingestion (Insert to Review Queue)
|
||||
|
||||
// Tag Validation
|
||||
if (!Array.isArray(result.suggested_tags)) {
|
||||
result.suggested_tags = [];
|
||||
}
|
||||
// Normalize: trim, lowercase, remove duplicates
|
||||
result.suggested_tags = [...new Set(result.suggested_tags.map(t => String(t).trim()).filter(t => t.length > 0))];
|
||||
ข้อมูลทั้งหมดที่ผ่าน n8n และ AI Model **จะต้องไม่ถูกอัพเดทเข้าตารางหลักอัตโนมัติ** แต่จะถูกบังคับนำเข้าตาราง Staging `migration_review_queue` แทน เพื่อรอมนุษย์จัดการผ่าน Frontend UI
|
||||
|
||||
// Tag confidence validation
|
||||
if (typeof result.tag_confidence !== 'number' || result.tag_confidence < 0 || result.tag_confidence > 1) {
|
||||
result.tag_confidence = 0.5;
|
||||
}
|
||||
```
|
||||
**Status Routing Policy:**
|
||||
- `confidence >= 0.85` และ `is_valid = true` -> Status **`PENDING`** (พร้อมรับ Batch Import)
|
||||
- `confidence >= 0.60` และ `< 0.85` -> Status **`PENDING`** (ติด Flag ให้ระวัง)
|
||||
- `confidence < 0.60` หรือ `is_valid = false` -> Status **`REJECTED`**
|
||||
- Parse Error / AI ไม่ตอบ -> **Error Log** (Node ถัดไป)
|
||||
|
||||
#### Node 3.5: Fallback Model Manager
|
||||
|
||||
- อัปเดต `migration_fallback_state` ทุกครั้งที่เกิด Parse Error
|
||||
- Auto-switch ไป `OLLAMA_MODEL_FALLBACK` เมื่อ Error ≥ `FALLBACK_ERROR_THRESHOLD`
|
||||
- ส่ง Alert Email เมื่อ Fallback ถูก Activate
|
||||
|
||||
#### Node 4: Confidence Router (4 outputs)
|
||||
|
||||
| เงื่อนไข | การดำเนินการ |
|
||||
| ------------------------------------------ | -------------------------------- |
|
||||
| `confidence >= 0.85` และ `is_valid = true` | **Output 0** → Auto Ingest |
|
||||
| `confidence >= 0.60` และ `< 0.85` | **Output 1** → Review Queue |
|
||||
| `confidence < 0.60` หรือ `is_valid = false` | **Output 2** → Reject Log |
|
||||
| Parse Error / AI ไม่ตอบ | **Output 3** → Error Log |
|
||||
| Fallback: Error > 5 ใน 10 Request | สลับ Model / หยุด Workflow + Alert |
|
||||
|
||||
**Revision Drift Protection:**
|
||||
```javascript
|
||||
// ถ้า Excel มี revision column — ตรวจสอบก่อน route
|
||||
if (item.json.excel_revision !== undefined) {
|
||||
const expectedRevision = (item.json.current_db_revision || 0) + 1;
|
||||
if (parseInt(item.json.excel_revision) !== expectedRevision) {
|
||||
item.json.review_reason = `Revision drift: Excel=${item.json.excel_revision}, Expected=${expectedRevision}`;
|
||||
reviewQueue.push(item);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Node 5A: Auto Ingest — Backend API
|
||||
|
||||
> ⚠️ **Storage Enforcement:** n8n ส่งแค่ `source_file_path` — Backend จะ generate UUID, enforce path strategy (`/share/np-dms/staging_ai/...`), และ move file atomically ผ่าน StorageService
|
||||
|
||||
```http
|
||||
POST /api/correspondences/import
|
||||
Authorization: Bearer <MIGRATION_TOKEN>
|
||||
Idempotency-Key: <document_number>:<batch_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Backend Tag Handling Logic:**
|
||||
|
||||
เมื่อ Backend รับ Payload พร้อม `ai_tags` ระบบจะ:
|
||||
|
||||
1. **Validate Tags:** ตรวจสอบว่า tag name อยู่ในรูปแบบที่ถูกต้อง (ไม่ว่าง, ไม่มีอักขระพิเศษ)
|
||||
2. **Create Missing Tags:** ถ้า Tag ไม่มีอยู่ใน `tags` table → สร้างใหม่โดยอัตโนมัติ
|
||||
```sql
|
||||
INSERT INTO tags (tag_name, created_by, created_at)
|
||||
VALUES ('<tag_name>', (SELECT user_id FROM users WHERE username = 'migration_bot'), NOW())
|
||||
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id);
|
||||
```
|
||||
3. **Link Document Tags:** บันทึกความสัมพันธ์ใน `correspondence_tags`
|
||||
```sql
|
||||
INSERT INTO correspondence_tags (correspondence_id, tag_id)
|
||||
SELECT LAST_INSERT_ID(), tag_id FROM tags WHERE tag_name IN (<ai_tags>);
|
||||
```
|
||||
4. **Tag Confidence Logging:** บันทึก `tag_confidence` ลงใน `details` JSON ของ Revision
|
||||
|
||||
Payload:
|
||||
```json
|
||||
{
|
||||
"document_number": "{{document_number}}",
|
||||
"title": "{{ai_result.suggested_title || title}}",
|
||||
"category": "{{ai_result.suggested_category}}",
|
||||
"source_file_path": "{{file_path}}",
|
||||
"ai_confidence": "{{ai_result.confidence}}",
|
||||
"ai_issues": "{{ai_result.detected_issues}}",
|
||||
"ai_tags": "{{ai_result.suggested_tags}}",
|
||||
"tag_confidence": "{{ai_result.tag_confidence}}",
|
||||
"migrated_by": "SYSTEM_IMPORT",
|
||||
"batch_id": "{{$env.MIGRATION_BATCH_ID}}"
|
||||
}
|
||||
```
|
||||
|
||||
**Audit Log ที่ Backend ต้องสร้าง:**
|
||||
```json
|
||||
{
|
||||
"action": "IMPORT",
|
||||
"source": "MIGRATION",
|
||||
"batch_id": "migration_20260226",
|
||||
"created_by": "SYSTEM_IMPORT",
|
||||
"metadata": {
|
||||
"migration": true,
|
||||
"batch_id": "migration_20260226",
|
||||
"ai_confidence": 0.91
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Checkpoint Update (ทุก 10 Records — ผ่าน IF Node + MariaDB Node):**
|
||||
**Insert into staging:**
|
||||
```sql
|
||||
INSERT INTO migration_progress (batch_id, last_processed_index, status)
|
||||
VALUES ('{{$env.MIGRATION_BATCH_ID}}', {{checkpoint_index}}, 'RUNNING')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_processed_index = {{checkpoint_index}},
|
||||
updated_at = NOW();
|
||||
INSERT INTO migration_review_queue (
|
||||
document_number, title, project_id, sender_organization_id, receiver_organization_id,
|
||||
received_date, issued_date, remarks, ai_suggested_category, ai_confidence,
|
||||
ai_issues, ai_summary, extracted_tags, temp_attachment_id, status
|
||||
) VALUES ( ... )
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status), ai_summary = VALUES(ai_summary);
|
||||
```
|
||||
|
||||
#### Node 5B: Review Queue
|
||||
#### Node 6: Error Log & Reject Log
|
||||
|
||||
> ⚠️ **`migration_review_queue` เป็น Temporary Table เท่านั้น** — ห้ามสร้าง Correspondence record จนกว่า Admin จะ Approve
|
||||
- Parse Error → เขียนลงไฟล์ `/share/np-dms/n8n/migration_logs/error_log.csv`
|
||||
- ทุก 10-50 ราบการอัพเดท MariaDB `migration_progress` เพื่อเป็น Checkpoint.
|
||||
|
||||
Approval Flow:
|
||||
```
|
||||
Review → Admin Approve → POST /api/correspondences/import (เหมือน Auto Ingest)
|
||||
Admin Reject → ลบออกจาก queue ไม่สร้าง record
|
||||
```
|
||||
---
|
||||
|
||||
#### Node 5C: Reject Log → `/share/np-dms/n8n/migration_logs/reject_log.csv`
|
||||
### Phase 4: Frontend Management & Final Commit (UI -> Backend API)
|
||||
|
||||
#### Node 5D: Error Log → `/share/np-dms/n8n/migration_logs/error_log.csv` + MariaDB
|
||||
1. หน้าจอ **Frontend Management UI** ดึงข้อมูลจาก `migration_review_queue`
|
||||
2. Admin สามารถ Browse & Edit ข้อมูล
|
||||
3. **Tag Review:** Admin สามารถพิจารณา Tags ที่เป็น `is_new: true` ว่าควรตีตก หรือเปลี่ยนไปแมตช์ของเดิม
|
||||
4. Admin กดปุ่ม **Execute Import** ส่งให้ Backend รัน Final Commit.
|
||||
5. Backend ยิงคำสั่งสร้าง Correspondence, นำ `temp_attachment_id` ไปผูกกับ Revision, ปรับเป็น `is_temporary = FALSE` และสร้าง/เชื่อม Tags จริง.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │Pre-flight │───▶│Fetch Categories│──▶│File Validator│ │
|
||||
│ │Checks │ │from Backend │ │+ Sanitize │ │
|
||||
│ │Pre-flight │───▶│DB Lookup & │──▶│File Upload & │ │
|
||||
│ │Checks │ │Data Fetch │ │Temp Storage │ │
|
||||
│ └─────────────┘ └──────────────┘ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────────┤ │
|
||||
@@ -57,9 +57,9 @@
|
||||
│ ┌─────────┘ │ └─────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────┐ ┌──────────┐ ┌────────┐ │
|
||||
│ │Auto │ │ Review │ │Reject │ │
|
||||
│ │Ingest│ │ Queue │ │Log │ │
|
||||
│ │+Chkpt│ │(DB only) │ │(CSV) │ │
|
||||
│ │Review│ │ Review │ │Reject │ │
|
||||
│ │Queue │ │ Queue │ │Log │ │
|
||||
│ │(AUTO)│ │(FLAGGED) │ │(CSV) │ │
|
||||
│ └──────┘ └──────────┘ └────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
@@ -227,70 +227,43 @@ mysql -h <DB_HOST> -u migration_bot -p lcbp3_production < lcbp3-v1.8.0-migration
|
||||
- เก็บค่า Config ทั้งหมดใน `$workflow.staticData.config`
|
||||
- อ่านผ่าน `$workflow.staticData.config.KEY` ใน Node อื่น
|
||||
|
||||
### Node 1-2: Pre-flight Checks
|
||||
- ตรวจสอบ Backend Health
|
||||
- ดึง Categories จาก `/api/master/correspondence-types`
|
||||
- ดึง Tags ที่มีอยู่แล้วจาก `/api/tags` (สำหรับ AI Tag Extraction)
|
||||
- ตรวจ File Mount (Read-only)
|
||||
- เก็บ Categories และ Existing Tags ใน `$workflow.staticData.systemCategories`
|
||||
### Node 1: Pre-flight Checks & Data Reader
|
||||
- ตรวจสอบ Backend Health และ Ollama Ping
|
||||
- อ่าน Checkpoint (`last_processed_index`) จาก `migration_progress`
|
||||
- Batch ข้อมูลจาก Excel ตามตาราง `BATCH_SIZE` ปกติ (50-100)
|
||||
- Normalize ข้อมูล UTF-8 (NFC) และสร้าง `original_index`
|
||||
|
||||
### Node 3: Read Checkpoint
|
||||
- อ่าน `last_processed_index` จาก `migration_progress`
|
||||
- ถ้าไม่มี เริ่มจาก 0
|
||||
### Node 2: DB Lookup & Categories Fetch
|
||||
- ดึง Categories จาก `/api/meta/categories` เพื่อเตรียม Prompt
|
||||
- Query ทะลวง DB: แปลงรหัสใน Excel (`project_code`, `sender`, `receiver`) ให้เป็น IDs จาก MariaDB
|
||||
- Query ดึง Master Tags ของโปรเจ็กต์: `SELECT tag_name, description FROM tags WHERE project_id = ...`
|
||||
- Output: แปลง ID เรียบร้อยและเตรียม `existing_tags_json` ให้ Ollama
|
||||
|
||||
### Node 4: Process Batch
|
||||
- อ่าน Excel
|
||||
- Normalize UTF-8 (NFC)
|
||||
- ตัด Batch ตาม `BATCH_SIZE`
|
||||
### Node 3: Text Extraction & Temp Upload
|
||||
- ใช้ **Apache Tika** (ผ่าน `Extract PDF Text` node หรือ HTTP Request) สกัดข้อความ (OCR/Text) ออกจาก PDF ใน staging
|
||||
- แนบไฟล์ไปยัง Backend: ยิง HTTP Request **`POST /api/storage/upload`** ของ Backend
|
||||
- รอรับผลลัพธ์เป็น `temp_attachment_id` (หมายความว่าไฟล์นี้เข้าข่าย Temporary ถูกเก็บจัดการใน NAS เรียบร้อยแล้ว)
|
||||
- Output: ไฟล์พร้อมใช้งาน, ได้เนื้อหา Text มาเตรียม prompt
|
||||
|
||||
### Node 5: File Validator
|
||||
- Sanitize filename (replace special chars)
|
||||
- Path traversal check
|
||||
- ตรวจสอบไฟล์มีอยู่จริง
|
||||
- **Output 2 ทาง**: Valid → AI, Error → Log
|
||||
### Node 4: AI Analysis
|
||||
- วาง System Prompt บังคับ Output JSON
|
||||
- โยน Metadata (Title, Date, DB Lookups) พร้อม Extracted PDF Text คุยกับ **Ollama `llama3.2:3b`**
|
||||
- ให้ AI วิเคราะห์ และสรุปเป็น `ai_summary`
|
||||
- ให้ AI แนะนำ Tags ใหม่หรือเลือก Tags เดิมจาก `existing_tags_json`
|
||||
|
||||
### Node 6: Build AI Prompt
|
||||
- ดึง Categories จาก `staticData` (ไม่ hardcode)
|
||||
- เลือก Model ตาม Fallback State
|
||||
- สร้าง Prompt ตาม Template พร้อม **Tag Extraction Instructions**
|
||||
- AI จะวิเคราะห์ Title และ Document Number เพื่อสกัด Tags ที่เกี่ยวข้อง
|
||||
### Node 5: Parse & Validate
|
||||
- Schema Validation (ดูให้แน่ใจว่า AI ตอบ `is_valid`, `confidence`, `summary`, `suggested_tags`)
|
||||
- Normalizing categories, trimming tags (`is_new: true / false` flag สำคัญมาก)
|
||||
- จัดชุดค่า Status ใหม่
|
||||
|
||||
### Node 7: Ollama AI Analysis
|
||||
- เรียก `POST /api/generate`
|
||||
- Timeout 30 วินาที
|
||||
- Retry 3 ครั้ง (n8n built-in)
|
||||
- AI Response รวม `suggested_tags` และ `tag_confidence`
|
||||
### Node 6: Confidence Router & Staging Ingest
|
||||
**แยกสาย 4 สาย:**
|
||||
1. **PENDING (Auto Ready):** (`confidence ≥ 0.85` && `is_valid = true`) → INSERT เข้า `migration_review_queue`
|
||||
2. **PENDING (Flagged):** (`confidence 0.60 - 0.84`) → INSERT เข้า `migration_review_queue` พร้อม Highlight/Remarks ให้ Admin ดูละเอียด
|
||||
3. **REJECTED:** (`confidence < 0.60` หรือ `is_valid = false`) → INSERT เข้า `migration_review_queue` สถานะรอแก้แบบ Manual
|
||||
4. **Error/Parse Fail:** ไปลง CSV Reject Log + DB `migration_errors`
|
||||
|
||||
### Node 8: Parse & Validate
|
||||
- Parse JSON Response
|
||||
- Schema Validation (is_valid, confidence, detected_issues)
|
||||
- **Tag Validation**: Normalize tags (trim, lowercase, deduplicate)
|
||||
- Enum Validation (ตรวจ Category ว่าอยู่ใน List หรือไม่)
|
||||
- **Output 2 ทาง**: Success → Router, Error → Fallback
|
||||
|
||||
### Node 9: Confidence Router
|
||||
- **4 Outputs**:
|
||||
1. Auto Ingest (confidence ≥ 0.85 && is_valid)
|
||||
2. Review Queue (0.60 ≤ confidence < 0.85)
|
||||
3. Reject Log (confidence < 0.60 หรือ is_valid = false)
|
||||
4. Error Log (parse error)
|
||||
|
||||
### Node 10A: Auto Ingest
|
||||
- POST `/api/migration/import`
|
||||
- Header: `Idempotency-Key: {doc_num}:{batch_id}`
|
||||
- Payload รวม **ai_tags** และ **tag_confidence**
|
||||
- Backend จะสร้าง Tags ที่ยังไม่มี และผูกกับเอกสารอัตโนมัติ
|
||||
- บันทึก Checkpoint ทุก 10 records
|
||||
|
||||
### Node 10B: Review Queue
|
||||
- INSERT เข้า `migration_review_queue` เท่านั้น
|
||||
- ยังไม่สร้าง Correspondence
|
||||
|
||||
### Node 10C: Reject Log
|
||||
- เขียน CSV ที่ `/home/node/.n8n-files/migration_logs/reject_log.csv`
|
||||
|
||||
### Node 10D: Error Log
|
||||
- เขียน CSV + INSERT เข้า `migration_errors`
|
||||
**สำคัญมาก:** *n8n จะทำหน้าที่สูบข้อมูลและจัดเตรียมเข้า `migration_review_queue` เท่านั้น จะไม่มีการข้ามขั้นตอนไป Import ลงตารางหลัก `correspondences` อัตโนมัติ (Final Commit ต้องทำบน Frontend UI)*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ T+1 เดือน:
|
||||
| Dry Run 2 AI Category Accuracy | ≥ 90% (Manual Spot-check 50 docs) | Human Review |
|
||||
| Idempotency Test: รัน Batch ซ้ำ | 0 Duplicate Records | SQL Count |
|
||||
| Organization Mapping ครบ | 100% | Lookup Table review |
|
||||
| Frontend Review UI พร้อมใช้งาน | ✅ | UAT Passed สำหรับหน้าจออนุมัติ |
|
||||
| Migration Bot Token Active + Whitelisted | ✅ | API Test |
|
||||
| Staging NAS Space: ≥ 500GB free | ✅ | QNAP Dashboard |
|
||||
|
||||
@@ -202,7 +203,7 @@ T+1 เดือน:
|
||||
|-------|---------|
|
||||
| Tier 1 Migration: 100% เสร็จ + Verified | ✅ |
|
||||
| Tier 2 Migration: ≥ 90% เสร็จ + Verified | ✅ |
|
||||
| Review Queue: ≤ 5% ค้างอยู่ (Critical Tier 1 = 0%) | ✅ |
|
||||
| Review Queue (รวมการพิจารณา AI New Tags): ≤ 5% ค้างอยู่ (Critical Tier 1 = 0%) | ✅ |
|
||||
| Migration Bot Token: REVOKED | ✅ |
|
||||
| Integrity Queries ผ่านทั้งหมด | ✅ |
|
||||
| Legacy System ยังเข้าถึงได้ (Read-only Fallback) | ✅ |
|
||||
@@ -229,7 +230,7 @@ T+1 เดือน:
|
||||
| **Organization Lookup Table** | Superadmin (NAP) | สร้างก่อน T-6 |
|
||||
| **Tier 1 Document List** | Document Control ทุก Org | ยืนยัน T-5 |
|
||||
| **Daily Monitoring (n8n Runs)** | Nattanin P. | T-3 ถึง Go-Live |
|
||||
| **Admin Review Queue** | Document Control (สค.) | ทุกเช้าวันทำงาน |
|
||||
| **Admin Review Queue & AI Tag Approval** | Document Control (สค.) | ทุกเช้าวันทำงาน (บังคับตรวจสอบ New Tags) |
|
||||
| **Post-migration Verification** | Nattanin P. | After each Gate |
|
||||
| **Legacy System Archival** | กทท. IT + NAP | T+30 |
|
||||
|
||||
|
||||
@@ -25,9 +25,18 @@ CREATE TABLE IF NOT EXISTS migration_review_queue (
|
||||
document_number VARCHAR(100) NOT NULL,
|
||||
title TEXT,
|
||||
original_title TEXT,
|
||||
project_id INT NULL COMMENT 'Project ID จาก Lookups',
|
||||
sender_organization_id INT NULL COMMENT 'Sender ID จาก Lookups',
|
||||
receiver_organization_id INT NULL COMMENT 'Receiver ID จาก Lookups',
|
||||
received_date DATE NULL COMMENT 'วันที่รับเอกสาร',
|
||||
issued_date DATE NULL COMMENT 'วันที่ออกเอกสาร',
|
||||
remarks TEXT COMMENT 'หมายเหตุจากหน้างาน (response)',
|
||||
ai_suggested_category VARCHAR(50),
|
||||
ai_confidence DECIMAL(4, 3),
|
||||
ai_issues JSON,
|
||||
ai_summary TEXT COMMENT 'สรุปเนื้อหาจาก AI (4-5 บรรทัด)',
|
||||
extracted_tags JSON COMMENT 'Tag ที่ AI นำเสนอหรือจับคู่ได้',
|
||||
temp_attachment_id INT NULL COMMENT 'ID ของไฟล์ชั่วคราวจาก Two-Phase Storage',
|
||||
review_reason VARCHAR(255),
|
||||
STATUS ENUM('PENDING', 'APPROVED', 'REJECTED') DEFAULT 'PENDING',
|
||||
reviewed_by VARCHAR(100),
|
||||
|
||||
@@ -85,14 +85,15 @@
|
||||
| Component | รายละเอียด |
|
||||
| ---------------------- | ------------------------------------------------------------------------------- |
|
||||
| Migration Orchestrator | n8n (Docker บน QNAP NAS) |
|
||||
| AI Model Primary | Ollama `llama3.2:3b` |
|
||||
| AI Model Primary | Ollama `llama3.2:3b` (Validation, Summarization, Tagging) |
|
||||
| AI Model Fallback | Ollama `mistral:7b-instruct-q4_K_M` |
|
||||
| Hardware | QNAP NAS (Orchestrator) + Desktop Desk-5439 (AI Processing, RTX 2060 SUPER 8GB) |
|
||||
| Data Ingestion | RESTful API + Migration Token (7 วัน) + Idempotency-Key Header |
|
||||
| Concurrency | Sequential — 1 Request/ครั้ง, Delay 2 วินาที |
|
||||
| Checkpoint | MariaDB `migration_progress` |
|
||||
| DB Lookup (n8n) | n8n ทำการ Query `project_id`, `organization_id` และดึง `Tags` จาก DB ให้ AI |
|
||||
| Data Ingestion | 1. Staging ลง `migration_review_queue` -> 2. กดยืนยันผ่าน Frontend Management UI -> 3. Final Commit ผ่าน API |
|
||||
| Concurrency (n8n) | Sequential — Batch Size 50-100 ป้องกัน DB Connection Overload |
|
||||
| Checkpoint | MariaDB `migration_progress` และการใช้ `ON DUPLICATE KEY UPDATE` ใน Staging |
|
||||
| Fallback | Auto-switch Model เมื่อ Error ≥ Threshold |
|
||||
| Storage | Backend StorageService เท่านั้น — ห้าม move file โดยตรง |
|
||||
| Storage | Two-Phase Storage: 1. `POST /api/storage/upload` (Temp) -> 2. Commit ภายหลัง |
|
||||
| Expected Runtime | ~16.6 ชั่วโมง (~3–4 คืน) สำหรับ 20,000 records |
|
||||
|
||||
---
|
||||
@@ -105,31 +106,42 @@
|
||||
"confidence": 0.92,
|
||||
"suggested_category": "Correspondence",
|
||||
"detected_issues": [],
|
||||
"suggested_title": null
|
||||
"suggested_title": null,
|
||||
"summary": "This document outlines the revised design specifications for the electrical subsystem in phase 2...",
|
||||
"suggested_tags": [
|
||||
{ "name": "Electrical", "description": "Electrical engineering design documents.", "is_new": false },
|
||||
{ "name": "Phase2-Specs", "description": "Specific requirements for Phase 2 implementation.", "is_new": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | คำอธิบาย |
|
||||
| -------------------- | ------------------------- | --------------------------- |
|
||||
| `is_valid` | boolean | เอกสารผ่านการตรวจสอบหรือไม่ |
|
||||
| `is_valid` | boolean | เอกสารผ่านการตรวจสอบหรือไม่ (เปรียบเทียบ subject vs pdf) |
|
||||
| `confidence` | float (0.0–1.0) | ความมั่นใจของ AI |
|
||||
| `suggested_category` | string (enum จาก Backend) | หมวดหมู่ที่ AI แนะนำ |
|
||||
| `detected_issues` | string[] | รายการปัญหา (array ว่างถ้าไม่มี) |
|
||||
| `suggested_title` | string \| null | Title ที่แก้ไขแล้ว หรือ null |
|
||||
| `summary` | string | สรุปเนื้อหา 4-5 ประโยค สำหรับใส่ใน `body` |
|
||||
| `suggested_tags` | array of objects | รายการ Tags ที่จับคู่ได้ หรือ แนะนำให้สร้างใหม่ (`is_new: true`) |
|
||||
|
||||
> ⚠️ **Patch:** `suggested_category` ต้องตรงกับ System Enum จาก `GET /api/meta/categories` เท่านั้น — ห้าม hardcode Category List ใน Prompt
|
||||
|
||||
---
|
||||
|
||||
## Confidence Threshold Policy
|
||||
## Confidence Threshold Policy (Staging Logic)
|
||||
|
||||
| ระดับ Confidence | การดำเนินการ |
|
||||
**ข้อมูลทุกชุดจาก n8n จะต้องถูกส่งเข้าตาราง `migration_review_queue` เสมอ** โดยจัดสถานะเบื้องต้นตาม Confidence:
|
||||
|
||||
| ระดับ Confidence | สถานะใน Review Queue |
|
||||
| ------------------------------- | --------------------------------------- |
|
||||
| `>= 0.85` และ `is_valid = true` | Auto Ingest เข้าระบบ |
|
||||
| `0.60–0.84` | ส่งไป Human Review Queue |
|
||||
| `< 0.60` หรือ `is_valid = false` | ส่งไป Reject Log รอ Manual Fix |
|
||||
| `>= 0.85` และ `is_valid = true` | `PENDING` (พร้อมให้ Admin เลือก Batch Import) |
|
||||
| `0.60–0.84` | `PENDING` (ไฮไลต์แจ้งให้ Admin ตรวจสอบข้อมูลก่อน) |
|
||||
| `< 0.60` หรือ `is_valid = false` | `REJECTED` (รอให้ Admin แก้ไขข้อมูล Manual) |
|
||||
| AI Parse Error | ส่งไป Error Log + Trigger Fallback Logic |
|
||||
| Revision Drift | ส่งไป Review Queue พร้อม reason |
|
||||
| Revision Drift | `PENDING` พร้อมระบุ reason: "Revision drift" |
|
||||
|
||||
> ⚠️ **Tag Review:** ข้อมูลใดที่มี `is_new: true` ใน `suggested_tags` จะถูกบังคับให้ Admin ตรวจสอบบน Frontend UI ก่อน เพื่อป้องกัน AI สร้าง Tags ขยะซ้ำซ้อน
|
||||
|
||||
---
|
||||
|
||||
@@ -161,32 +173,45 @@ Hard Rules:
|
||||
|
||||
---
|
||||
|
||||
## Storage Governance (Patch)
|
||||
## Storage Governance (Two-Phase Storage)
|
||||
|
||||
**ข้อห้าม:**
|
||||
```
|
||||
❌ mv /data/dms/staging_ai/TCC-COR-0001.pdf /final/path/...
|
||||
```
|
||||
|
||||
**ข้อบังคับ:**
|
||||
**ข้อบังคับ (Two-Phase Strategy):**
|
||||
|
||||
**Phase 1: Temp Upload (โดย n8n)**
|
||||
```
|
||||
✅ POST /api/correspondences/import
|
||||
body: { source_file_path: "/data/dms/staging_ai/TCC-COR-0001.pdf", ... }
|
||||
✅ POST /api/storage/upload
|
||||
(Upload ไฟล์ PDF ได้ผลลัพธ์เป็น attachment_id เช่น 1024)
|
||||
*ไฟล์จะถูกระบุเป็น `is_temporary = TRUE`*
|
||||
```
|
||||
|
||||
Backend จะ:
|
||||
1. Generate UUID
|
||||
2. Enforce path strategy: `/data/dms/uploads/YYYY/MM/{uuid}.pdf`
|
||||
3. Move file atomically ผ่าน StorageService
|
||||
4. Create revision folder ถ้าจำเป็น
|
||||
**Phase 2: Final Commit (โดย Frontend UI -> Backend API)**
|
||||
```
|
||||
✅ POST /api/migration/commit_batch
|
||||
body: { queue_ids: [1, 2, 3] }
|
||||
```
|
||||
|
||||
Backend จะทำหน้าที่:
|
||||
1. อ่านข้อมูลจาก `migration_review_queue` ซึ่งมี `temp_attachment_id` อยู่
|
||||
2. นำ `temp_attachment_id` ไปเชื่อมกับเอกสาร (Link to `correspondence_attachments`)
|
||||
3. เปลี่ยนสถานะอัพเดต `is_temporary = FALSE`
|
||||
4. Move ไฟล์ไปที่ `/data/dms/uploads/YYYY/MM/{uuid}.pdf` ผ่าน StorageService อย่างถูกต้อง
|
||||
|
||||
---
|
||||
|
||||
## Review Queue Contract
|
||||
## Review Queue Contract & Frontend UI
|
||||
|
||||
- `migration_review_queue` เป็น **Temporary Table เท่านั้น** — ไม่ใช่ Business Schema
|
||||
- ห้ามสร้าง Correspondence record จนกว่า Admin จะ Approve
|
||||
- Approval Flow: `Review → Admin Approve → POST /api/correspondences/import`
|
||||
- `migration_review_queue` เป็น **Staging Table หลัก** (ไม่ auto-ingest ข้ามขั้นตอนนี้)
|
||||
- ห้ามสร้าง Correspondence record จนกว่า Admin จะสั่ง Execute การ Import จากหน้าจอ
|
||||
- **Approval Flow:**
|
||||
1. N8N Insert เข้า `migration_review_queue` (พร้อม `temp_attachment_id`)
|
||||
2. Admin Review บน Frontend UI (ให้ความสำคัญกับการเช็ค `is_new: true` Tags)
|
||||
3. Admin เลือก Rows แล้วกด **"Execute Import"**
|
||||
4. Frontend ส่งคำสั่ง `POST /api/migration/commit_batch` ถือว่าเป็นการ Ingest ลงตาราง Business Schema จริง
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user