feat(ai): implement unified prompt management UX/UI (ADR-037)
- Add context config endpoints (GET/PUT /api/ai/prompts/:type/:version/context-config) - Add execution profile endpoints (CRUD /api/ai/execution-profiles) - Add sandbox RAG Prep endpoint (POST /api/ai/admin/sandbox/rag-prep) - Create Prompt Management UI with multi-type support - Add ContextConfigEditor, PromptEditor, RuntimeParametersPanel components - Add SandboxTabs for 3-step workflow (OCR, Extract, RAG Prep) - Add database deltas for ai_execution_profiles and additional prompt types - Update quickstart.md with production backend URLs - Add comprehensive test coverage for new features
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
|
||||
// - 2026-05-21: เพิ่มการลงทะเบียน QUEUE_AI_BATCH และ enqueueSandboxJob สำหรับ Superadmin sandbox.
|
||||
// - 2026-05-21: แก้ไข ESLint error โดยการเปลี่ยน Queue<any> เป็น Queue<unknown> สำหรับ batchQueue
|
||||
// - 2026-06-14: เพิ่ม sandbox-rag-prep ใน enqueueSandboxJob (T039)
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue, JobsOptions } from 'bullmq';
|
||||
@@ -122,7 +123,8 @@ export class AiQueueService {
|
||||
| 'sandbox-rag'
|
||||
| 'sandbox-extract'
|
||||
| 'sandbox-ocr-only'
|
||||
| 'sandbox-ai-extract',
|
||||
| 'sandbox-ai-extract'
|
||||
| 'sandbox-rag-prep',
|
||||
payload: {
|
||||
idempotencyKey: string;
|
||||
projectPublicId?: string;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// - 2026-06-11: แก้ไขการส่งพารามิเตอร์ให้กับ queueSuggestJob ใน suggestDocumentMetadata
|
||||
// - 2026-06-13: T024-T026 — เพิ่ม sandbox parameter endpoints (GET/PUT/POST reset) ตาม ADR-036
|
||||
// - 2026-06-13: T036, T037, T039, T040, T041 — เพิ่ม endpoints apply sandbox profile และ get production parameters พร้อม idempotency, CASL, validation และ audit
|
||||
// - 2026-06-14: เพิ่ม POST /ai/admin/sandbox/rag-prep endpoint (T033)
|
||||
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
|
||||
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
import { AiRagService } from './ai-rag.service';
|
||||
import { AiQueueService } from './ai-queue.service';
|
||||
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
|
||||
import { SandboxRagPrepDto } from './dto/sandbox-rag-prep.dto';
|
||||
import { ExtractDocumentDto } from './dto/extract-document.dto';
|
||||
import { AiCallbackDto } from './dto/ai-callback.dto';
|
||||
import { CreateAiJobDto } from './dto/create-ai-job.dto';
|
||||
@@ -430,6 +432,7 @@ export class AiController {
|
||||
@ApiBearerAuth()
|
||||
@RequirePermission('system.manage_all')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'AI Admin Sandbox RAG Query — ส่ง sandbox RAG เข้า queue ai-batch (T035)',
|
||||
@@ -483,6 +486,7 @@ export class AiController {
|
||||
@RequirePermission('system.manage_all')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'AI Admin Sandbox OCR Extract — อัปโหลดไฟล์เพื่อทำ OCR Sandbox (T041 & T042)',
|
||||
@@ -542,6 +546,7 @@ export class AiController {
|
||||
@RequirePermission('system.manage_all')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@ApiOperation({
|
||||
summary: 'Step 1: Run OCR Only — สำหรับตรวจคุณภาพ OCR ก่อนทดสอบ AI',
|
||||
description:
|
||||
@@ -636,6 +641,7 @@ export class AiController {
|
||||
@Post('admin/sandbox/ai-extract')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@RequirePermission('system.manage_all')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@ApiOperation({
|
||||
summary: 'Step 2: Run AI Extraction — ใช้ OCR text ที่ cache จาก Step 1',
|
||||
description:
|
||||
@@ -668,6 +674,29 @@ export class AiController {
|
||||
return { requestPublicId, jobId, status: 'queued' };
|
||||
}
|
||||
|
||||
@Post('admin/sandbox/rag-prep')
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@RequirePermission('system.manage_all')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@ApiOperation({
|
||||
summary: 'Step 3: Run RAG Prep Sandbox testing (T033)',
|
||||
description:
|
||||
'รับข้อความ OCR และ profileId แล้วรัน semantic chunking และ embedding preview',
|
||||
})
|
||||
async submitSandboxRagPrep(
|
||||
@Body() dto: SandboxRagPrepDto
|
||||
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
|
||||
const requestPublicId = uuidv7();
|
||||
const jobId = await this.aiQueueService.enqueueSandboxJob(
|
||||
'sandbox-rag-prep',
|
||||
{
|
||||
idempotencyKey: requestPublicId,
|
||||
extraPayload: { text: dto.text, profileId: dto.profileId },
|
||||
}
|
||||
);
|
||||
return { requestPublicId, jobId, status: 'queued' };
|
||||
}
|
||||
|
||||
// --- Webhook Callback จาก n8n (Service Account) ---
|
||||
|
||||
@Post('callback')
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// File: backend/src/modules/ai/dto/context-config.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created ContextConfigDto for prompt context management (conforming to task T006)
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsInt, IsString, IsObject, Min } from 'class-validator';
|
||||
|
||||
export class ContextFilterDto {
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
projectId!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractId!: string | null;
|
||||
}
|
||||
|
||||
export class ContextConfigDto {
|
||||
@ApiPropertyOptional({ type: ContextFilterDto, nullable: true })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filter?: ContextFilterDto | null;
|
||||
|
||||
@ApiProperty({ type: Number, minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize!: number;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
@IsString()
|
||||
language!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
@IsString()
|
||||
outputLanguage!: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// File: backend/src/modules/ai/dto/create-execution-profile.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created CreateExecutionProfileDto for AI execution profile creation (conforming to task T008)
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateExecutionProfileDto {
|
||||
@ApiProperty({ description: 'Profile Name' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
profileName!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Canonical Model',
|
||||
enum: ['np-dms-ai', 'np-dms-ocr'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
canonicalModel?: 'np-dms-ai' | 'np-dms-ocr';
|
||||
|
||||
@ApiProperty({ description: 'Temperature parameter' })
|
||||
@IsNumber()
|
||||
@Min(0.0)
|
||||
@Max(1.0)
|
||||
temperature!: number;
|
||||
|
||||
@ApiProperty({ description: 'Top-P parameter' })
|
||||
@IsNumber()
|
||||
@Min(0.0)
|
||||
@Max(1.0)
|
||||
topP!: number;
|
||||
|
||||
@ApiProperty({ description: 'Repeat penalty parameter' })
|
||||
@IsNumber()
|
||||
@Min(1.0)
|
||||
@Max(2.0)
|
||||
repeatPenalty!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum tokens to generate' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Context window size (num_ctx / ctxSize)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
ctxSize?: number | null;
|
||||
|
||||
@ApiProperty({ description: 'Keep alive in seconds' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
keepAlive!: number;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// File: backend/src/modules/ai/dto/sandbox-rag-prep.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created SandboxRagPrepDto for Sandbox RAG Prep testing (conforming to task T007)
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class SandboxRagPrepDto {
|
||||
@ApiProperty({ description: 'Text to prepare for RAG (OCR text)' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
text!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Execution profile public ID to use' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
profileId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// File: backend/src/modules/ai/dto/update-execution-profile.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: Created UpdateExecutionProfileDto for AI execution profile updates (conforming to task T009)
|
||||
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsNumber, IsInt, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateExecutionProfileDto {
|
||||
@ApiPropertyOptional({ description: 'Temperature parameter' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.0)
|
||||
@Max(1.0)
|
||||
temperature?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Top-P parameter' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.0)
|
||||
@Max(1.0)
|
||||
topP?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Repeat penalty parameter' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1.0)
|
||||
@Max(2.0)
|
||||
repeatPenalty?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum tokens to generate' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Context window size' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
ctxSize?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Keep alive in seconds' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
keepAlive?: number;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: backend/src/modules/ai/processors/ai-batch.processor.ts
|
||||
// Change Log
|
||||
// - 2026-06-08: แก้ไขปัญหา LLM JSON response truncated โดยการเพิ่ม num_ctx เป็น 16384 ใน sandbox-extract, sandbox-ai-extract และ migrate-document (แก้ไขโดย AGY Gemini 3.5 Flash (Medium))
|
||||
// - 2026-06-14: เพิ่ม case sandbox-rag-prep และ processSandboxRagPrep (T035)
|
||||
// - 2026-05-15: เพิ่ม processor สำหรับ ai-batch queue ตาม ADR-023A.
|
||||
// - 2026-05-15: เพิ่ม EmbeddingService สำหรับ embed-document logic (T022).
|
||||
// - 2026-05-21: เพิ่มการรองรับ sandbox-rag และ sandbox-extract สำหรับ Superadmin sandbox.
|
||||
@@ -70,6 +71,7 @@ export type AiBatchJobType =
|
||||
| 'sandbox-extract'
|
||||
| 'sandbox-ocr-only'
|
||||
| 'sandbox-ai-extract'
|
||||
| 'sandbox-rag-prep'
|
||||
| 'migrate-document'
|
||||
| 'rag-prepare'
|
||||
| 'ai-suggest'
|
||||
@@ -294,7 +296,10 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
async process(job: Job<AiBatchJobData>): Promise<void> {
|
||||
const isSandbox =
|
||||
job.data.jobType === 'sandbox-rag' ||
|
||||
job.data.jobType === 'sandbox-extract';
|
||||
job.data.jobType === 'sandbox-extract' ||
|
||||
job.data.jobType === 'sandbox-ocr-only' ||
|
||||
job.data.jobType === 'sandbox-ai-extract' ||
|
||||
job.data.jobType === 'sandbox-rag-prep';
|
||||
if (!isSandbox) {
|
||||
await this.setAiProcessingStatus(job.data.documentPublicId, 'PROCESSING');
|
||||
}
|
||||
@@ -362,6 +367,12 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
);
|
||||
await this.processSandboxAiExtract(job.data);
|
||||
return;
|
||||
case 'sandbox-rag-prep':
|
||||
this.logger.log(
|
||||
`Sandbox RAG Prep job processing — jobId=${String(job.id)}`
|
||||
);
|
||||
await this.processSandboxRagPrep(job.data);
|
||||
return;
|
||||
case 'migrate-document':
|
||||
this.logger.log(
|
||||
`Migrate document job processing — jobId=${String(job.id)}`
|
||||
@@ -1530,4 +1541,149 @@ export class AiBatchProcessor extends WorkerHost {
|
||||
const confidence = suggestion['confidenceScore'];
|
||||
return typeof confidence === 'number' ? confidence : undefined;
|
||||
}
|
||||
|
||||
private async processSandboxRagPrep(data: AiBatchJobData): Promise<void> {
|
||||
const { idempotencyKey, payload } = data;
|
||||
const text = payload.text as string;
|
||||
const profileId = payload.profileId as string | undefined;
|
||||
await this.redis.setex(
|
||||
`ai:rag:result:${idempotencyKey}`,
|
||||
3600,
|
||||
JSON.stringify({
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'processing',
|
||||
})
|
||||
);
|
||||
try {
|
||||
if (!text) {
|
||||
throw new Error('text is required for sandbox-rag-prep job');
|
||||
}
|
||||
const activePrompt =
|
||||
await this.aiPromptsService.getActive('rag_prep_prompt');
|
||||
if (!activePrompt) {
|
||||
throw new Error('No active rag_prep_prompt version found');
|
||||
}
|
||||
const promptText = activePrompt.template
|
||||
.replace('{{text}}', text)
|
||||
.replace('{{ocr_text}}', text);
|
||||
let sandboxParams;
|
||||
if (profileId) {
|
||||
try {
|
||||
sandboxParams =
|
||||
await this.aiPolicyService.getSandboxParameters(profileId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch sandbox parameters for profileId=${profileId}: ${String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!sandboxParams) {
|
||||
try {
|
||||
sandboxParams =
|
||||
await this.aiPolicyService.getSandboxParameters('standard');
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch sandbox parameters for standard: ${String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const generateOptions = {
|
||||
options: {
|
||||
num_ctx: sandboxParams?.numCtx ?? 8192,
|
||||
num_predict: sandboxParams?.maxTokens ?? 4096,
|
||||
temperature: sandboxParams?.temperature,
|
||||
top_p: sandboxParams?.topP,
|
||||
repeat_penalty: sandboxParams?.repeatPenalty,
|
||||
},
|
||||
};
|
||||
const llmOutput = await this.ollamaService.generate(
|
||||
promptText,
|
||||
generateOptions
|
||||
);
|
||||
const parsed = this.parseChunkTags(llmOutput);
|
||||
const chunks =
|
||||
parsed.length > 0 ? parsed : this.fixedSizeChunk(text, 512, 64);
|
||||
const ragChunks: Array<{ text: string; summary: string }> = [];
|
||||
const ragVectors: number[][] = [];
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
const embedResult = await this.ocrService.embedViaSidecar(chunk.text);
|
||||
ragChunks.push({
|
||||
text: chunk.text,
|
||||
summary: chunk.topic,
|
||||
});
|
||||
ragVectors.push(embedResult.dense);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Sandbox embed failed for chunk: ${chunk.topic}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.redis.setex(
|
||||
`ai:rag:result:${idempotencyKey}`,
|
||||
3600,
|
||||
JSON.stringify({
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'completed',
|
||||
ragChunks,
|
||||
ragVectors,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Sandbox RAG Prep failed: ${errMsg}`);
|
||||
await this.redis.setex(
|
||||
`ai:rag:result:${idempotencyKey}`,
|
||||
3600,
|
||||
JSON.stringify({
|
||||
requestPublicId: idempotencyKey,
|
||||
status: 'failed',
|
||||
errorMessage: errMsg,
|
||||
completedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private parseChunkTags(
|
||||
llmOutput: string
|
||||
): Array<{ topic: string; text: string }> {
|
||||
const chunks: Array<{ topic: string; text: string }> = [];
|
||||
const regex = /<chunk\s+topic="([^"]*)"\s*>([\s\S]*?)<\/chunk\s*>/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(llmOutput)) !== null) {
|
||||
const topic = match[1]?.trim() || 'ทั่วไป';
|
||||
const text = match[2]?.trim();
|
||||
if (text) {
|
||||
chunks.push({ topic, text });
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private fixedSizeChunk(
|
||||
text: string,
|
||||
chunkSize: number,
|
||||
overlap: number
|
||||
): Array<{ topic: string; text: string }> {
|
||||
const chunks: Array<{ topic: string; text: string }> = [];
|
||||
const cleanText = text.replace(/\s+/g, ' ').trim();
|
||||
const textLength = cleanText.length;
|
||||
let startIndex = 0;
|
||||
let chunkIndex = 0;
|
||||
while (startIndex < textLength) {
|
||||
const endIndex = Math.min(startIndex + chunkSize, textLength);
|
||||
const chunkText = cleanText.substring(startIndex, endIndex);
|
||||
chunks.push({
|
||||
topic: `ส่วนที่ ${chunkIndex + 1}`,
|
||||
text: chunkText,
|
||||
});
|
||||
startIndex += chunkSize - overlap;
|
||||
chunkIndex += 1;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
@@ -26,6 +27,7 @@ import { AiPrompt } from './ai-prompts.entity';
|
||||
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
||||
import { UpdatePromptNoteDto } from './dto/update-prompt-note.dto';
|
||||
import { AiPromptResponseDto } from './dto/ai-prompt-response.dto';
|
||||
import { ContextConfigDto } from '../dto/context-config.dto';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../../common/guards/rbac.guard';
|
||||
@@ -137,4 +139,42 @@ export class AiPromptsController {
|
||||
);
|
||||
return { data: this.mapToDto(updated) };
|
||||
}
|
||||
|
||||
@Get(':promptType/:versionNumber/context-config')
|
||||
@RequirePermission('system.manage_all')
|
||||
@ApiOperation({ summary: 'ดึง Context Config ของ Prompt Version ที่กำหนด' })
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiParam({ name: 'versionNumber', type: Number })
|
||||
async getContextConfig(
|
||||
@Param('promptType') promptType: string,
|
||||
@Param('versionNumber', ParseIntPipe) versionNumber: number
|
||||
): Promise<{ data: Record<string, unknown> | null }> {
|
||||
const config = await this.promptsService.getContextConfig(
|
||||
promptType,
|
||||
versionNumber
|
||||
);
|
||||
return { data: config };
|
||||
}
|
||||
|
||||
@Put(':promptType/:versionNumber/context-config')
|
||||
@RequirePermission('system.manage_all')
|
||||
@Audit('ai_prompt.update_context_config', 'AiPrompt')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'อัปเดต Context Config ของ Prompt Version ที่กำหนด',
|
||||
})
|
||||
@ApiParam({ name: 'promptType', example: 'ocr_extraction' })
|
||||
@ApiParam({ name: 'versionNumber', type: Number })
|
||||
async updateContextConfig(
|
||||
@Param('promptType') promptType: string,
|
||||
@Param('versionNumber', ParseIntPipe) versionNumber: number,
|
||||
@Body() dto: ContextConfigDto
|
||||
): Promise<{ data: Record<string, unknown> }> {
|
||||
const updated = await this.promptsService.updateContextConfig(
|
||||
promptType,
|
||||
versionNumber,
|
||||
dto
|
||||
);
|
||||
return { data: updated };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ describe('AiPromptsService', () => {
|
||||
});
|
||||
});
|
||||
describe('create', () => {
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder', async () => {
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{ocr_text}} placeholder สำหรับ ocr_extraction', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
'ocr_extraction',
|
||||
@@ -232,6 +232,36 @@ describe('AiPromptsService', () => {
|
||||
)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{query}} หรือ {{context}} placeholder สำหรับ rag_query_prompt', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
'rag_query_prompt',
|
||||
{ template: 'Invalid template context' },
|
||||
1
|
||||
)
|
||||
).rejects.toThrow(ValidationException);
|
||||
await expect(
|
||||
service.create(
|
||||
'rag_query_prompt',
|
||||
{ template: 'Invalid template query {{query}}' },
|
||||
1
|
||||
)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{text}} placeholder สำหรับ rag_prep_prompt', async () => {
|
||||
await expect(
|
||||
service.create('rag_prep_prompt', { template: 'Invalid template' }, 1)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
it('ควรปฏิเสธ template ที่ไม่มี {{document_text}} placeholder สำหรับ classification_prompt', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
'classification_prompt',
|
||||
{ template: 'Invalid template' },
|
||||
1
|
||||
)
|
||||
).rejects.toThrow(ValidationException);
|
||||
});
|
||||
it('ควรปฏิเสธ template ที่ตัวอักษรเกิน 4,000 ตัว', async () => {
|
||||
const longTemplate = 'a'.repeat(4005) + '{{ocr_text}}';
|
||||
await expect(
|
||||
@@ -363,4 +393,84 @@ describe('AiPromptsService', () => {
|
||||
expect(mockAiPromptRepo.findOne).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextConfig CRUD', () => {
|
||||
it('ควร getContextConfig สำเร็จ', async () => {
|
||||
const prompt = {
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
contextConfig: {
|
||||
pageSize: 5,
|
||||
language: 'th',
|
||||
outputLanguage: 'th',
|
||||
filter: null,
|
||||
},
|
||||
};
|
||||
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
|
||||
const result = await service.getContextConfig('ocr_extraction', 1);
|
||||
expect(result).toEqual(prompt.contextConfig);
|
||||
});
|
||||
|
||||
it('ควรโยน NotFoundException เมื่อ getContextConfig ไม่พบเวอร์ชัน', async () => {
|
||||
mockAiPromptRepo.findOne.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.getContextConfig('ocr_extraction', 99)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('ควร updateContextConfig สำเร็จและตรวจสอบโครงการ/สัญญาสำเร็จ', async () => {
|
||||
const prompt = {
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
contextConfig: null,
|
||||
};
|
||||
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
|
||||
mockAiPromptRepo.save.mockResolvedValue({
|
||||
...prompt,
|
||||
contextConfig: {
|
||||
pageSize: 5,
|
||||
language: 'th',
|
||||
outputLanguage: 'th',
|
||||
filter: { projectId: 'p-1', contractId: 'c-1' },
|
||||
},
|
||||
});
|
||||
|
||||
// จำลองให้โครงการและสัญญาถูกต้องใน DB
|
||||
mockQueryBuilder.getRawOne
|
||||
.mockResolvedValueOnce({ id: 10 }) // project check
|
||||
.mockResolvedValueOnce({ id: 20 }); // contract check
|
||||
|
||||
const result = await service.updateContextConfig('ocr_extraction', 1, {
|
||||
pageSize: 5,
|
||||
language: 'th',
|
||||
outputLanguage: 'th',
|
||||
filter: { projectId: 'p-1', contractId: 'c-1' },
|
||||
});
|
||||
|
||||
expect(result.pageSize).toBe(5);
|
||||
expect(mockAiPromptRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ควรโยน NotFoundException เมื่อ updateContextConfig ส่ง project UUID ที่ไม่มีอยู่ใน DB', async () => {
|
||||
const prompt = {
|
||||
id: 1,
|
||||
promptType: 'ocr_extraction',
|
||||
versionNumber: 1,
|
||||
contextConfig: null,
|
||||
};
|
||||
mockAiPromptRepo.findOne.mockResolvedValue(prompt);
|
||||
mockQueryBuilder.getRawOne.mockResolvedValueOnce(null); // project not found
|
||||
|
||||
await expect(
|
||||
service.updateContextConfig('ocr_extraction', 1, {
|
||||
pageSize: 5,
|
||||
language: 'th',
|
||||
outputLanguage: 'th',
|
||||
filter: { projectId: 'invalid-proj-uuid', contractId: null },
|
||||
})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { randomUUID } from 'crypto';
|
||||
import { AiPrompt } from './ai-prompts.entity';
|
||||
import { AuditLog } from '../../../common/entities/audit-log.entity';
|
||||
import { CreateAiPromptDto } from './dto/create-ai-prompt.dto';
|
||||
import { ContextConfigDto } from '../dto/context-config.dto';
|
||||
import {
|
||||
BusinessException,
|
||||
ValidationException,
|
||||
@@ -343,8 +344,31 @@ export class AiPromptsService {
|
||||
dto: CreateAiPromptDto,
|
||||
userId: number
|
||||
): Promise<AiPrompt> {
|
||||
if (!dto.template.includes('{{ocr_text}}')) {
|
||||
throw new ValidationException('template ต้องมี {{ocr_text}} placeholder');
|
||||
if (promptType === 'ocr_extraction') {
|
||||
if (!dto.template.includes('{{ocr_text}}')) {
|
||||
throw new ValidationException(
|
||||
'template ต้องมี {{ocr_text}} placeholder'
|
||||
);
|
||||
}
|
||||
} else if (promptType === 'rag_query_prompt') {
|
||||
if (
|
||||
!dto.template.includes('{{query}}') ||
|
||||
!dto.template.includes('{{context}}')
|
||||
) {
|
||||
throw new ValidationException(
|
||||
'template ต้องมี {{query}} และ {{context}} placeholder'
|
||||
);
|
||||
}
|
||||
} else if (promptType === 'rag_prep_prompt') {
|
||||
if (!dto.template.includes('{{text}}')) {
|
||||
throw new ValidationException('template ต้องมี {{text}} placeholder');
|
||||
}
|
||||
} else if (promptType === 'classification_prompt') {
|
||||
if (!dto.template.includes('{{document_text}}')) {
|
||||
throw new ValidationException(
|
||||
'template ต้องมี {{document_text}} placeholder'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (dto.template.length > 4000) {
|
||||
throw new ValidationException('Template exceeds 4,000 character limit');
|
||||
@@ -527,6 +551,76 @@ export class AiPromptsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Context Config ของ Prompt Version ที่กำหนด
|
||||
*/
|
||||
async getContextConfig(
|
||||
promptType: string,
|
||||
versionNumber: number
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
return prompt.contextConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดต Context Config ของ Prompt Version ที่กำหนด พร้อมทั้งตรวจเช็คความถูกต้องของโครงการและสัญญาใน DB
|
||||
*/
|
||||
async updateContextConfig(
|
||||
promptType: string,
|
||||
versionNumber: number,
|
||||
dto: ContextConfigDto
|
||||
): Promise<Record<string, unknown>> {
|
||||
const prompt = await this.aiPromptRepo.findOne({
|
||||
where: { promptType, versionNumber },
|
||||
});
|
||||
if (!prompt) {
|
||||
throw new NotFoundException('AiPrompt', versionNumber.toString());
|
||||
}
|
||||
|
||||
// Validation (T027): ตรวจสอบโครงการ/สัญญาใน DB
|
||||
if (dto.filter?.projectId) {
|
||||
const projectExists = (await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('p.id')
|
||||
.from('projects', 'p')
|
||||
.where('p.uuid = :uuid', { uuid: dto.filter.projectId })
|
||||
.andWhere('p.deleted_at IS NULL')
|
||||
.getRawOne()) as unknown;
|
||||
if (!projectExists) {
|
||||
throw new NotFoundException('Project', dto.filter.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.filter?.contractId) {
|
||||
const contractExists = (await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.select('c.id')
|
||||
.from('contracts', 'c')
|
||||
.where('c.uuid = :uuid', { uuid: dto.filter.contractId })
|
||||
.getRawOne()) as unknown;
|
||||
if (!contractExists) {
|
||||
throw new NotFoundException('Contract', dto.filter.contractId);
|
||||
}
|
||||
}
|
||||
|
||||
// บันทึกลง DB
|
||||
const newContextConfig = {
|
||||
filter: dto.filter || null,
|
||||
pageSize: dto.pageSize,
|
||||
language: dto.language,
|
||||
outputLanguage: dto.outputLanguage,
|
||||
};
|
||||
prompt.contextConfig = newContextConfig;
|
||||
await this.aiPromptRepo.save(prompt);
|
||||
|
||||
return newContextConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกข้อมูลการปฏิบัติการของผู้ใช้ลงในตารางหลัก audit_logs
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// - 2026-06-03: สร้าง unit test สำหรับ OllamaService ครอบคลุม generate() model option,
|
||||
// getOcrModelName(), และ loadModel() keepAlive param ตาม ADR-034
|
||||
// - 2026-06-13: ADR-036 — อัปเดต expected model tags เป็น np-dms-ai/np-dms-ocr
|
||||
// - 2026-06-14: เพิ่ม tests สำหรับ generateEmbedding, checkHealth, unloadModel เพื่อเพิ่ม branch coverage
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -132,5 +133,125 @@ describe('OllamaService (ADR-034)', () => {
|
||||
expect(result).toBe(false);
|
||||
expect(mockedAxios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
it('ควรคืน false และ log error เมื่อ axios throw ระหว่าง loadModel', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
const result = await service.loadModel('np-dms-ai:latest');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('getEmbeddingModelName()', () => {
|
||||
it('ควรคืน nomic-embed-text เป็น embedding model', () => {
|
||||
expect(service.getEmbeddingModelName()).toBe('nomic-embed-text');
|
||||
});
|
||||
});
|
||||
describe('generateEmbedding()', () => {
|
||||
it('ควรคืน embedding vector เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
|
||||
const mockVector = [0.1, 0.2, 0.3];
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: { embedding: mockVector },
|
||||
});
|
||||
const result = await service.generateEmbedding('test text');
|
||||
expect(result).toEqual(mockVector);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/embeddings'),
|
||||
expect.objectContaining({
|
||||
model: 'nomic-embed-text',
|
||||
prompt: 'test text',
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควร throw error เมื่อ Ollama embedding ล้มเหลว', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Embedding failed'));
|
||||
await expect(service.generateEmbedding('test')).rejects.toThrow(
|
||||
'Embedding failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('checkHealth()', () => {
|
||||
it('ควรคืน HEALTHY พร้อมโมเดลที่โหลดอยู่จาก /api/ps เมื่อ Ollama ตอบกลับสำเร็จ', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: {} }) // /api/tags
|
||||
.mockResolvedValueOnce({
|
||||
data: { models: [{ name: 'np-dms-ai:latest' }] },
|
||||
}); // /api/ps
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('HEALTHY');
|
||||
expect(result.models).toContain('np-dms-ai:latest');
|
||||
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
it('ควรคืน HEALTHY พร้อม fallback models เมื่อ /api/ps ไม่มีข้อมูล', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
|
||||
.mockResolvedValueOnce({ data: { models: [] } }); // /api/ps empty
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('HEALTHY');
|
||||
expect(result.models).toContain('np-dms-ai:latest'); // fallback
|
||||
});
|
||||
it('ควรคืน HEALTHY แม้ /api/ps throw error (graceful degradation)', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ data: {} }) // /api/tags OK
|
||||
.mockRejectedValueOnce(new Error('ps endpoint error')); // /api/ps fails
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('HEALTHY');
|
||||
});
|
||||
it('ควรคืน DEGRADED เมื่อ /api/tags timeout', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('timeout error'));
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('DEGRADED');
|
||||
expect(result.error).toContain('timeout');
|
||||
});
|
||||
it('ควรคืน DEGRADED เมื่อ error message มี code ECONNABORTED', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('code ECONNABORTED'));
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('DEGRADED');
|
||||
});
|
||||
it('ควรคืน DOWN เมื่อ connection ถูกปฏิเสธ (ไม่ใช่ timeout)', async () => {
|
||||
mockedAxios.get = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
const result = await service.checkHealth();
|
||||
expect(result.status).toBe('DOWN');
|
||||
});
|
||||
});
|
||||
describe('unloadModel()', () => {
|
||||
it('ควรคืน true เมื่อ unload สำเร็จ', async () => {
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: {} });
|
||||
const result = await service.unloadModel('np-dms-ocr:latest');
|
||||
expect(result).toBe(true);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/generate'),
|
||||
expect.objectContaining({ model: 'np-dms-ocr:latest', keep_alive: 0 }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
it('ควรคืน false เมื่อ unload ล้มเหลว', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Unload failed'));
|
||||
const result = await service.unloadModel('np-dms-ocr:latest');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('generate() error path', () => {
|
||||
it('ควร throw error เมื่อ Ollama generate ล้มเหลว', async () => {
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('LLM timeout'));
|
||||
await expect(service.generate('test prompt')).rejects.toThrow(
|
||||
'LLM timeout'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// File: src/modules/ai/services/sandbox-ocr-engine.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: สร้าง unit tests สำหรับ SandboxOcrEngineService ครอบคลุม detectAndExtract ทุก engine
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import { SandboxOcrEngineService } from './sandbox-ocr-engine.service';
|
||||
import { OcrService } from './ocr.service';
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('fs');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
const mockedFs = fs as jest.Mocked<typeof fs>;
|
||||
|
||||
/** OcrService mock สำหรับ tesseract/fast-path */
|
||||
const mockOcrService = {
|
||||
detectAndExtract: jest.fn(),
|
||||
};
|
||||
|
||||
/** ConfigService mock */
|
||||
const mockConfigService = {
|
||||
get: jest.fn(<T>(key: string, defaultValue?: T): T | undefined => {
|
||||
const cfg: Record<string, unknown> = {
|
||||
OCR_API_URL: 'http://localhost:8765',
|
||||
OCR_SIDECAR_API_KEY: 'test-api-key-2026',
|
||||
};
|
||||
return (cfg[key] as T | undefined) ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
describe('SandboxOcrEngineService', () => {
|
||||
let service: SandboxOcrEngineService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SandboxOcrEngineService,
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: OcrService, useValue: mockOcrService },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<SandboxOcrEngineService>(SandboxOcrEngineService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=auto', () => {
|
||||
it('ควร route ไปยัง OcrService เมื่อ engine=auto', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'auto extracted text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
|
||||
expect(result.text).toBe('auto extracted text');
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
expect(mockOcrService.detectAndExtract).toHaveBeenCalledWith({
|
||||
pdfPath: '/tmp/file.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('ควรใช้ fast-path engineUsed เมื่อ OcrService คืน ocrUsed=false', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'embedded text',
|
||||
ocrUsed: false,
|
||||
});
|
||||
const result = await service.detectAndExtract('/tmp/file.pdf', 'auto');
|
||||
expect(result.engineUsed).toBe('fast-path');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=tesseract', () => {
|
||||
it('ควร route ไปยัง OcrService เมื่อ engine=tesseract', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'tesseract text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/file.pdf',
|
||||
'tesseract'
|
||||
);
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=typhoon-np-dms-ocr (legacy alias)', () => {
|
||||
it('ควรแปลง typhoon-np-dms-ocr เป็น np-dms-ocr และส่งไปยัง sidecar', async () => {
|
||||
const mockBuffer = Buffer.from('pdf content');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
text: 'ocr text via alias',
|
||||
ocrUsed: true,
|
||||
engineUsed: 'np-dms-ocr',
|
||||
},
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/file.pdf',
|
||||
'typhoon-np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('ocr text via alias');
|
||||
expect(result.engineUsed).toBe('np-dms-ocr');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — engine=np-dms-ocr (sidecar path)', () => {
|
||||
it('ควรส่ง file ไปยัง sidecar /ocr-upload สำเร็จ', async () => {
|
||||
const mockBuffer = Buffer.from('pdf binary data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: {
|
||||
text: 'extracted from typhoon',
|
||||
ocrUsed: true,
|
||||
engineUsed: 'np-dms-ocr',
|
||||
},
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('extracted from typhoon');
|
||||
expect(result.ocrUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('np-dms-ocr');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/ocr-upload'),
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-API-Key': 'test-api-key-2026',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรส่ง typhoonOptions (temperature, topP, repeatPenalty) ไปใน form data', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: { text: 'result', ocrUsed: true, engineUsed: 'np-dms-ocr' },
|
||||
});
|
||||
await service.detectAndExtract('/tmp/doc.pdf', 'np-dms-ocr', {
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
repeatPenalty: 1.2,
|
||||
});
|
||||
expect(mockedAxios.post).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ควรใช้ fallback values เมื่อ sidecar response ไม่มี text/ocrUsed/engineUsed', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockResolvedValueOnce({
|
||||
data: {},
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('');
|
||||
expect(result.ocrUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('np-dms-ocr'); // resolvedEngineType fallback
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง Tesseract เมื่อ fs.readFileSync ล้มเหลว (outer catch fallback)', async () => {
|
||||
(mockedFs.readFileSync as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('ENOENT: file not found');
|
||||
});
|
||||
// service จะ catch error และ fallback ไปยัง Tesseract
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'tesseract fallback text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/missing.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง Tesseract เมื่อ sidecar HTTP error เกิดขึ้น', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest.fn().mockRejectedValueOnce(
|
||||
Object.assign(new Error('Request failed'), {
|
||||
response: { status: 500, data: { detail: 'Internal Server Error' } },
|
||||
})
|
||||
);
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'tesseract fallback result',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.text).toBe('tesseract fallback result');
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
expect(result.engineUsed).toBe('tesseract');
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง fast-path เมื่อ sidecar error และ OcrService ส่ง ocrUsed=false', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'embedded text',
|
||||
ocrUsed: false,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.engineUsed).toBe('fast-path');
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — default engine (no arg)', () => {
|
||||
it('ควรใช้ auto เป็น default engine เมื่อไม่ระบุ engineType', async () => {
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'default text',
|
||||
ocrUsed: false,
|
||||
});
|
||||
const result = await service.detectAndExtract('/tmp/file.pdf');
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAndExtract() — edge cases', () => {
|
||||
it('ควร handle axios error ที่ไม่มี response.status gracefully', async () => {
|
||||
const mockBuffer = Buffer.from('pdf data');
|
||||
(mockedFs.readFileSync as jest.Mock).mockReturnValueOnce(mockBuffer);
|
||||
mockedAxios.post = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Network unreachable'));
|
||||
mockOcrService.detectAndExtract.mockResolvedValueOnce({
|
||||
text: 'fallback text',
|
||||
ocrUsed: true,
|
||||
});
|
||||
const result = await service.detectAndExtract(
|
||||
'/tmp/doc.pdf',
|
||||
'np-dms-ocr'
|
||||
);
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
// File: backend/src/modules/ai/tests/ai-execution-profiles.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: สร้าง unit tests สำหรับ AiPolicyService ที่ครอบคลุม execution profile management (T041)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AiPolicyService } from '../services/ai-policy.service';
|
||||
import { AiExecutionProfile } from '../entities/ai-execution-profile.entity';
|
||||
import { AiSandboxProfile } from '../entities/ai-sandbox-profile.entity';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/** Mock Redis สำหรับ inject */
|
||||
const mockRedis = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
/** Mock repository สำหรับ AiExecutionProfile */
|
||||
const mockProfileRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
/** Mock repository สำหรับ AiSandboxProfile */
|
||||
const mockSandboxRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
/** สร้าง AiExecutionProfile stub */
|
||||
const makeProfile = (
|
||||
overrides: Partial<AiExecutionProfile> = {}
|
||||
): AiExecutionProfile =>
|
||||
({
|
||||
id: 1,
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai',
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
isActive: true,
|
||||
updatedBy: undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) as AiExecutionProfile;
|
||||
|
||||
/** สร้าง AiSandboxProfile stub */
|
||||
const makeSandbox = (
|
||||
overrides: Partial<AiSandboxProfile> = {}
|
||||
): AiSandboxProfile =>
|
||||
({
|
||||
id: 1,
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai',
|
||||
temperature: 0.6,
|
||||
topP: 0.9,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
updatedBy: undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
}) as AiSandboxProfile;
|
||||
|
||||
describe('AiPolicyService — Execution Profile Management (T041)', () => {
|
||||
let service: AiPolicyService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiPolicyService,
|
||||
{
|
||||
provide: getRepositoryToken(AiExecutionProfile),
|
||||
useValue: mockProfileRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AiSandboxProfile),
|
||||
useValue: mockSandboxRepo,
|
||||
},
|
||||
{
|
||||
provide: 'default_IORedisModuleConnectionToken',
|
||||
useValue: mockRedis,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<AiPolicyService>(AiPolicyService);
|
||||
});
|
||||
|
||||
// ─── getCanonicalModelName ───────────────────────────────────────────────────
|
||||
describe('getCanonicalModelName()', () => {
|
||||
it('ควรคืน np-dms-ocr เมื่อ modelName มีคำว่า ocr', () => {
|
||||
expect(service.getCanonicalModelName('np-dms-ocr:latest')).toBe(
|
||||
'np-dms-ocr'
|
||||
);
|
||||
});
|
||||
it('ควรคืน np-dms-ocr เมื่อ modelName มีคำว่า typhoon-np-dms-ocr', () => {
|
||||
expect(service.getCanonicalModelName('typhoon-np-dms-ocr:latest')).toBe(
|
||||
'np-dms-ocr'
|
||||
);
|
||||
});
|
||||
it('ควรคืน np-dms-ai สำหรับ model ทั่วไปที่ไม่มีคำว่า ocr', () => {
|
||||
expect(service.getCanonicalModelName('np-dms-ai:latest')).toBe(
|
||||
'np-dms-ai'
|
||||
);
|
||||
});
|
||||
it('ควรคืน np-dms-ai สำหรับ typhoon2.5 model (main model)', () => {
|
||||
expect(service.getCanonicalModelName('typhoon2.5-np-dms:latest')).toBe(
|
||||
'np-dms-ai'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getProfileForJobType ────────────────────────────────────────────────────
|
||||
describe('getProfileForJobType()', () => {
|
||||
it('ควรคืน quality สำหรับ auto-fill-document', () => {
|
||||
expect(service.getProfileForJobType('auto-fill-document')).toBe(
|
||||
'quality'
|
||||
);
|
||||
});
|
||||
it('ควรคืน quality สำหรับ migrate-document', () => {
|
||||
expect(service.getProfileForJobType('migrate-document')).toBe('quality');
|
||||
});
|
||||
it('ควรคืน standard สำหรับ rag-query', () => {
|
||||
expect(service.getProfileForJobType('rag-query')).toBe('standard');
|
||||
});
|
||||
it('ควรคืน interactive สำหรับ intent-classify', () => {
|
||||
expect(service.getProfileForJobType('intent-classify')).toBe(
|
||||
'interactive'
|
||||
);
|
||||
});
|
||||
it('ควรคืน interactive สำหรับ tool-suggest', () => {
|
||||
expect(service.getProfileForJobType('tool-suggest')).toBe('interactive');
|
||||
});
|
||||
it('ควรคืน deep-analysis สำหรับ sandbox-analysis', () => {
|
||||
expect(service.getProfileForJobType('sandbox-analysis')).toBe(
|
||||
'deep-analysis'
|
||||
);
|
||||
});
|
||||
it('ควรคืน standard เป็น default สำหรับ ocr-extract', () => {
|
||||
expect(service.getProfileForJobType('ocr-extract')).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getProfileParameters ────────────────────────────────────────────────────
|
||||
describe('getProfileParameters()', () => {
|
||||
it('ควรคืนจาก Redis cache เมื่อมี cache hit', async () => {
|
||||
const cachedPolicy = {
|
||||
canonicalModel: 'np-dms-ai',
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
};
|
||||
mockRedis.get.mockResolvedValueOnce(JSON.stringify(cachedPolicy));
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result.temperature).toBe(0.5);
|
||||
expect(mockProfileRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง DB เมื่อ Redis cache miss', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null); // cache miss
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ temperature: 0.3 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result.temperature).toBe(0.3);
|
||||
expect(mockProfileRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง hardcoded defaults เมื่อ DB ก็ไม่มีข้อมูล', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(null); // ไม่มีใน DB
|
||||
const result = await service.getProfileParameters('quality');
|
||||
expect(result.temperature).toBe(0.1); // default quality profile
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง DB เมื่อ Redis throw error', async () => {
|
||||
mockRedis.get.mockRejectedValueOnce(new Error('Redis CONN error'));
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง defaults เมื่อ DB ก็ throw error', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockRejectedValueOnce(new Error('DB timeout'));
|
||||
const result = await service.getProfileParameters('interactive');
|
||||
expect(result.temperature).toBe(0.7); // default interactive profile
|
||||
});
|
||||
|
||||
it('ควรไม่ throw เมื่อ cache write ล้มเหลว (graceful)', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||
mockRedis.set.mockRejectedValueOnce(new Error('Redis write failed'));
|
||||
const result = await service.getProfileParameters('standard');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getModelDefaults ────────────────────────────────────────────────────────
|
||||
describe('getModelDefaults()', () => {
|
||||
it('ควรคืนจาก Redis cache เมื่อมี cache hit', async () => {
|
||||
const cachedOcrPolicy = {
|
||||
canonicalModel: 'np-dms-ocr',
|
||||
temperature: 0.1,
|
||||
topP: 0.1,
|
||||
maxTokens: null,
|
||||
numCtx: null,
|
||||
repeatPenalty: 1.1,
|
||||
keepAliveSeconds: 0,
|
||||
};
|
||||
mockRedis.get.mockResolvedValueOnce(JSON.stringify(cachedOcrPolicy));
|
||||
const result = await service.getModelDefaults('np-dms-ocr');
|
||||
expect(result.canonicalModel).toBe('np-dms-ocr');
|
||||
expect(result.temperature).toBe(0.1);
|
||||
});
|
||||
|
||||
it('ควร fallback ไปยัง DB เมื่อ Redis cache miss', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ canonicalModel: 'np-dms-ocr', temperature: 0.05 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const result = await service.getModelDefaults('np-dms-ocr');
|
||||
expect(result.temperature).toBe(0.05);
|
||||
});
|
||||
|
||||
it('ควรคืน defaultOcrPolicy เมื่อไม่มีใน DB (np-dms-ocr)', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(null);
|
||||
const result = await service.getModelDefaults('np-dms-ocr');
|
||||
expect(result.canonicalModel).toBe('np-dms-ocr');
|
||||
expect(result.keepAliveSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('ควรคืน standard defaults เมื่อไม่มีใน DB (np-dms-ai)', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(null);
|
||||
const result = await service.getModelDefaults('np-dms-ai');
|
||||
expect(result.canonicalModel).toBe('np-dms-ai');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── saveSandboxDraft ────────────────────────────────────────────────────────
|
||||
describe('saveSandboxDraft()', () => {
|
||||
it('ควรอัปเดต draft ที่มีอยู่แล้ว', async () => {
|
||||
const existingDraft = makeSandbox({ temperature: 0.5 });
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(existingDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||
...existingDraft,
|
||||
temperature: 0.8,
|
||||
});
|
||||
const result = await service.saveSandboxDraft('standard', {
|
||||
temperature: 0.8,
|
||||
});
|
||||
expect(result.temperature).toBe(0.8);
|
||||
});
|
||||
|
||||
it('ควรสร้าง draft ใหม่จาก production เมื่อยังไม่มี draft', async () => {
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(null); // ไม่มี draft
|
||||
// getProductionPolicy → getProfileParameters → Redis miss → DB
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(makeProfile());
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const newDraft = makeSandbox({ topP: 0.9 });
|
||||
mockSandboxRepo.create.mockReturnValueOnce(newDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({ ...newDraft, topP: 0.9 });
|
||||
const result = await service.saveSandboxDraft(
|
||||
'standard',
|
||||
{ topP: 0.9 },
|
||||
1
|
||||
);
|
||||
expect(result.topP).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resetSandboxToProduction ────────────────────────────────────────────────
|
||||
describe('resetSandboxToProduction()', () => {
|
||||
it('ควร reset draft ที่มีอยู่ให้ตรงกับ production', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ temperature: 0.5 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
const existingDraft = makeSandbox({ temperature: 0.9 });
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(existingDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||
...existingDraft,
|
||||
temperature: 0.5,
|
||||
});
|
||||
const result = await service.resetSandboxToProduction('standard', 1);
|
||||
expect(result.temperature).toBe(0.5);
|
||||
});
|
||||
|
||||
it('ควรสร้าง draft ใหม่เมื่อยังไม่มี draft แล้ว reset ไปยัง production', async () => {
|
||||
mockRedis.get.mockResolvedValueOnce(null);
|
||||
mockProfileRepo.findOne.mockResolvedValueOnce(
|
||||
makeProfile({ temperature: 0.5 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValueOnce('OK');
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(null); // ไม่มี draft
|
||||
const newDraft = makeSandbox();
|
||||
mockSandboxRepo.create.mockReturnValueOnce(newDraft);
|
||||
mockSandboxRepo.save.mockResolvedValueOnce({
|
||||
...newDraft,
|
||||
temperature: 0.5,
|
||||
});
|
||||
const result = await service.resetSandboxToProduction('standard');
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createJobPayload ────────────────────────────────────────────────────────
|
||||
describe('createJobPayload()', () => {
|
||||
it('ควรสร้าง payload ที่ถูกต้องสำหรับ rag-query job', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue(makeProfile());
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const payload = await service.createJobPayload(
|
||||
'rag-query',
|
||||
'doc-id-123',
|
||||
'att-id-456'
|
||||
);
|
||||
expect(payload.jobType).toBe('rag-query');
|
||||
expect(payload.documentPublicId).toBe('doc-id-123');
|
||||
expect(payload.canonicalModel).toBe('np-dms-ai');
|
||||
expect(payload.snapshotParams.temperature).toBeDefined();
|
||||
expect(payload.ocrSnapshotParams).toBeUndefined(); // rag-query ไม่มี OCR snapshot
|
||||
});
|
||||
|
||||
it('ควรสร้าง payload ที่มี ocrSnapshotParams สำหรับ migrate-document job', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue(makeProfile());
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const payload = await service.createJobPayload(
|
||||
'migrate-document',
|
||||
'doc-id-789'
|
||||
);
|
||||
expect(payload.canonicalModel).toBe('np-dms-ai'); // main model for migrate
|
||||
expect(payload.ocrSnapshotParams).toBeDefined();
|
||||
expect(payload.ocrSnapshotParams?.temperature).toBeDefined();
|
||||
});
|
||||
|
||||
it('ควรสร้าง payload ที่ใช้ np-dms-ocr สำหรับ ocr-extract job', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue(
|
||||
makeProfile({ canonicalModel: 'np-dms-ocr', temperature: 0.1 })
|
||||
);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
const payload = await service.createJobPayload(
|
||||
'ocr-extract',
|
||||
'doc-id-ocr'
|
||||
);
|
||||
expect(payload.canonicalModel).toBe('np-dms-ocr');
|
||||
expect(payload.ocrSnapshotParams).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyProfile validation ─────────────────────────────────────────────────
|
||||
describe('applyProfile() — parameter validation', () => {
|
||||
it('ควรโยน BadRequestException เมื่อ temperature > 1', async () => {
|
||||
const draft = makeSandbox({ temperature: 1.5, profileName: 'standard' });
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard', 1)).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรโยน BadRequestException เมื่อ topP < 0', async () => {
|
||||
const draft = makeSandbox({
|
||||
temperature: 0.5,
|
||||
topP: -0.1,
|
||||
profileName: 'standard',
|
||||
});
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard', 1)).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรโยน BadRequestException เมื่อ repeatPenalty < 1', async () => {
|
||||
const draft = makeSandbox({
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
repeatPenalty: 0.9,
|
||||
profileName: 'standard',
|
||||
});
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard')).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it('ควรโยน BadRequestException เมื่อ keepAliveSeconds < 0', async () => {
|
||||
const draft = makeSandbox({
|
||||
temperature: 0.5,
|
||||
topP: 0.8,
|
||||
repeatPenalty: 1.1,
|
||||
keepAliveSeconds: -1,
|
||||
profileName: 'standard',
|
||||
});
|
||||
mockSandboxRepo.findOne.mockResolvedValueOnce(draft);
|
||||
await expect(service.applyProfile('standard')).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// File: backend/src/modules/ai/tests/vram-monitor.service.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-11: สร้าง unit tests สำหรับ VramMonitorService (US5)
|
||||
// - 2026-06-14: เพิ่ม tests สำหรับ getVramStatus และ invalidateCache เพื่อเพิ่ม branch/function coverage
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -99,4 +100,61 @@ describe('VramMonitorService', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('getVramStatus', () => {
|
||||
it('ควรคืน status ที่ถูกต้องเมื่อ Ollama คืน models', async () => {
|
||||
mockedAxios.get
|
||||
.mockResolvedValueOnce({
|
||||
// first call: /api/ps ใน getVramStatus
|
||||
data: {
|
||||
models: [
|
||||
{ name: 'np-dms-ai:latest', size_vram: 3 * 1024 * 1024 * 1024 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// second call: /api/ps ใน getVramHeadroom
|
||||
data: {
|
||||
models: [
|
||||
{ name: 'np-dms-ai:latest', size_vram: 3 * 1024 * 1024 * 1024 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const status = await service.getVramStatus(4000);
|
||||
expect(status.loadedModels).toContain('np-dms-ai:latest');
|
||||
expect(status.totalVramMb).toBe(8192);
|
||||
expect(status.hasCapacity).toBe(true); // 8192MB - 3072MB = 5120MB free > 4000MB required
|
||||
});
|
||||
it('ควรคืน hasCapacity=true เมื่อมี VRAM เหลือเพียงพอ', async () => {
|
||||
mockedAxios.get
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
models: [
|
||||
{ name: 'np-dms-ai:latest', size_vram: 1 * 1024 * 1024 * 1024 },
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
models: [
|
||||
{ name: 'np-dms-ai:latest', size_vram: 1 * 1024 * 1024 * 1024 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const status = await service.getVramStatus(4000);
|
||||
// 8192MB total - 1024MB used = 7168MB free > 4000MB
|
||||
expect(status.hasCapacity).toBe(true);
|
||||
});
|
||||
it('ควรคืน fallback (hasCapacity=false) เมื่อ /api/ps ล้มเหลว', async () => {
|
||||
mockedAxios.get.mockRejectedValue(new Error('Network error'));
|
||||
const status = await service.getVramStatus();
|
||||
expect(status.hasCapacity).toBe(false);
|
||||
expect(status.freeVramMb).toBe(0);
|
||||
expect(status.loadedModels).toEqual([]);
|
||||
});
|
||||
});
|
||||
describe('invalidateCache', () => {
|
||||
it('ควร resolve โดยไม่ throw (no-op)', async () => {
|
||||
await expect(service.invalidateCache()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
Request,
|
||||
Param,
|
||||
Query,
|
||||
@@ -36,6 +37,8 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
|
||||
import { ValidationException } from '../../common/exceptions';
|
||||
import { IdempotencyInterceptor } from '../../common/interceptors/idempotency.interceptor';
|
||||
import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface';
|
||||
|
||||
@ApiTags('Correspondences')
|
||||
@@ -52,6 +55,8 @@ export class CorrespondenceController {
|
||||
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
|
||||
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
|
||||
@RequirePermission('workflow.action_review')
|
||||
@Audit('correspondence.workflow_action', 'correspondence')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
processAction(
|
||||
@Body() actionDto: WorkflowActionDto,
|
||||
@Request() req: RequestWithUser
|
||||
@@ -62,7 +67,9 @@ export class CorrespondenceController {
|
||||
|
||||
// Use Unified Workflow Engine via CorrespondenceWorkflowService
|
||||
if (!actionDto.instanceId) {
|
||||
throw new Error('instanceId is required for workflow action');
|
||||
throw new ValidationException(
|
||||
'instanceId is required for workflow action'
|
||||
);
|
||||
}
|
||||
|
||||
return this.workflowService.processAction(
|
||||
@@ -85,6 +92,7 @@ export class CorrespondenceController {
|
||||
})
|
||||
@RequirePermission('correspondence.create')
|
||||
@Audit('correspondence.create', 'correspondence')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
create(
|
||||
@Body() createDto: CreateCorrespondenceDto,
|
||||
@Request() req: RequestWithUser
|
||||
@@ -125,6 +133,7 @@ export class CorrespondenceController {
|
||||
})
|
||||
@RequirePermission('correspondence.create')
|
||||
@Audit('correspondence.submit', 'correspondence')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
async submit(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() submitDto: SubmitCorrespondenceDto,
|
||||
@@ -158,8 +167,9 @@ export class CorrespondenceController {
|
||||
status: 200,
|
||||
description: 'Correspondence updated successfully.',
|
||||
})
|
||||
@RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit'
|
||||
@RequirePermission('correspondence.edit')
|
||||
@Audit('correspondence.update', 'correspondence')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
async update(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() updateDto: UpdateCorrespondenceDto,
|
||||
@@ -241,6 +251,7 @@ export class CorrespondenceController {
|
||||
@ApiOperation({ summary: 'Bulk cancel correspondences (Org Admin+)' })
|
||||
@RequirePermission('correspondence.cancel')
|
||||
@Audit('correspondence.bulk_cancel', 'correspondence')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
async bulkCancel(
|
||||
@Body() dto: BulkCancelDto,
|
||||
@Request() req: RequestWithUser
|
||||
@@ -274,6 +285,7 @@ export class CorrespondenceController {
|
||||
})
|
||||
@RequirePermission('correspondence.cancel')
|
||||
@Audit('correspondence.cancel', 'correspondence')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
async cancel(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() cancelDto: CancelCorrespondenceDto,
|
||||
|
||||
@@ -890,6 +890,11 @@ export class CorrespondenceService {
|
||||
const updated = await this.findOne(id);
|
||||
|
||||
// Re-index updated document in Elasticsearch (fire-and-forget)
|
||||
// ใช้ status จริงจาก current revision แทนการ hardcode 'DRAFT'
|
||||
const currentRevisionStatus =
|
||||
updated.revisions?.find((r) => r.isCurrent)?.status?.statusCode ??
|
||||
updated.revisions?.[0]?.status?.statusCode ??
|
||||
'DRAFT';
|
||||
void this.searchService.indexDocument({
|
||||
id: updated.id,
|
||||
publicId: updated.publicId,
|
||||
@@ -897,7 +902,7 @@ export class CorrespondenceService {
|
||||
docNumber: updated.correspondenceNumber,
|
||||
title: updateDto.subject ?? updated.revisions?.[0]?.subject,
|
||||
description: updateDto.description ?? updated.revisions?.[0]?.description,
|
||||
status: 'DRAFT',
|
||||
status: currentRevisionStatus,
|
||||
projectId: updated.projectId,
|
||||
createdAt: updated.createdAt,
|
||||
});
|
||||
@@ -1141,7 +1146,10 @@ export class CorrespondenceService {
|
||||
try {
|
||||
await this.cancel(publicId, reason, user);
|
||||
succeeded.push(publicId);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Bulk cancel failed for ${publicId}: ${(err as Error).message}`
|
||||
);
|
||||
failed.push(publicId);
|
||||
}
|
||||
}
|
||||
@@ -1150,7 +1158,12 @@ export class CorrespondenceService {
|
||||
}
|
||||
|
||||
async exportCsv(searchDto: SearchCorrespondenceDto): Promise<string> {
|
||||
const { data } = await this.findAll(searchDto);
|
||||
// ดึงทุกแถวที่ตรงเงื่อนไข — ไม่ใช้ pagination สำหรับ export
|
||||
const { data } = await this.findAll({
|
||||
...searchDto,
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
const header = [
|
||||
'Document No.',
|
||||
@@ -1182,9 +1195,12 @@ export class CorrespondenceService {
|
||||
}
|
||||
|
||||
private escapeCsv(value: string): string {
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
// กัน CSV formula injection (OWASP)
|
||||
let v = value;
|
||||
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
|
||||
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
|
||||
return `"${v.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,32 @@ import {
|
||||
IsObject,
|
||||
IsDateString,
|
||||
IsArray,
|
||||
IsIn,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* DTO ของผู้รับเอกสาร — ใช้กับ @ValidateNested เพื่อตรวจสอบแต่ละ element ใน recipients array
|
||||
*/
|
||||
export class RecipientDto {
|
||||
@ApiProperty({
|
||||
description: 'Organization ID or UUID ของผู้รับ',
|
||||
example: '019505a1-7c3e-7000-8000-abc123def456',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
organizationId!: number | string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ประเภทผู้รับ: TO หรือ CC',
|
||||
enum: ['TO', 'CC'],
|
||||
example: 'TO',
|
||||
})
|
||||
@IsIn(['TO', 'CC'])
|
||||
type!: 'TO' | 'CC';
|
||||
}
|
||||
|
||||
export class CreateCorrespondenceDto {
|
||||
@ApiProperty({ description: 'Project ID or UUID', example: 1 })
|
||||
@IsNotEmpty()
|
||||
@@ -125,9 +148,15 @@ export class CreateCorrespondenceDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Recipients',
|
||||
example: [{ organizationId: 1, type: 'TO' }],
|
||||
example: [
|
||||
{ organizationId: '019505a1-7c3e-7000-8000-abc123def456', type: 'TO' },
|
||||
],
|
||||
type: () => RecipientDto,
|
||||
isArray: true,
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RecipientDto)
|
||||
recipients?: RecipientDto[];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
// File: src/modules/rfa/dto/submit-rfa.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: ตัด templateId ออก — ย้ายไปใช้ Unified Workflow Engine (ADR-001)
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class SubmitRfaDto {
|
||||
@ApiProperty({
|
||||
description: 'ID ของ Routing Template ที่จะใช้เดินเรื่อง',
|
||||
example: 1,
|
||||
})
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
templateId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'publicId ของ Review Team สำหรับ Parallel Review (ADR-019)',
|
||||
example: '019505a1-7c3e-7000-8000-abc123def456',
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// File: src/modules/rfa/rfa.controller.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Wire submit reviewTeamPublicId through to the submit workflow for parallel review task creation.
|
||||
// - 2026-06-14: ADR-016 Idempotency-Key enforcement on mutations; pass RBAC roles to Unified Workflow Engine; drop templateId.
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
@@ -51,13 +54,34 @@ export class RfaController {
|
||||
private readonly uuidResolver: UuidResolverService
|
||||
) {}
|
||||
|
||||
/** ADR-016: บังคับให้ทุก mutation ส่ง Idempotency-Key header */
|
||||
private assertIdempotencyKey(idempotencyKey?: string): void {
|
||||
if (!idempotencyKey) {
|
||||
throw new BadRequestException('Idempotency-Key header is required');
|
||||
}
|
||||
}
|
||||
|
||||
/** ดึง role name จาก user assignments เพื่อส่งให้ Unified Workflow Engine ตรวจ DSL requirements */
|
||||
private extractRoles(user: User): string[] {
|
||||
return (
|
||||
user.assignments
|
||||
?.map((a) => a.role?.roleName)
|
||||
.filter((name): name is string => Boolean(name)) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create new RFA (Draft)' })
|
||||
@ApiBody({ type: CreateRfaDto })
|
||||
@ApiResponse({ status: 201, description: 'RFA created successfully' })
|
||||
@RequirePermission('rfa.create')
|
||||
@Audit('rfa.create', 'rfa')
|
||||
create(@Body() createDto: CreateRfaDto, @CurrentUser() user: User) {
|
||||
create(
|
||||
@Body() createDto: CreateRfaDto,
|
||||
@CurrentUser() user: User,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
return this.rfaService.create(createDto, user);
|
||||
}
|
||||
|
||||
@@ -74,15 +98,17 @@ export class RfaController {
|
||||
async submit(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() submitDto: SubmitRfaDto,
|
||||
@CurrentUser() user: User
|
||||
@CurrentUser() user: User,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
// ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
|
||||
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
||||
return this.rfaService.submit(
|
||||
rfa.id,
|
||||
submitDto.templateId,
|
||||
user,
|
||||
submitDto.reviewTeamPublicId
|
||||
submitDto.reviewTeamPublicId,
|
||||
this.extractRoles(user)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,11 +128,18 @@ export class RfaController {
|
||||
async processAction(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() actionDto: WorkflowActionDto,
|
||||
@CurrentUser() user: User
|
||||
@CurrentUser() user: User,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
// ADR-019: resolve UUID → internal INT id
|
||||
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
|
||||
return this.rfaService.processAction(rfa.id, actionDto, user);
|
||||
return this.rfaService.processAction(
|
||||
rfa.id,
|
||||
actionDto,
|
||||
user,
|
||||
this.extractRoles(user)
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -145,8 +178,10 @@ export class RfaController {
|
||||
async update(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@Body() updateDto: UpdateRfaDto,
|
||||
@CurrentUser() user: User
|
||||
@CurrentUser() user: User,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
return this.rfaService.update(uuid, updateDto, user);
|
||||
}
|
||||
|
||||
@@ -159,8 +194,10 @@ export class RfaController {
|
||||
@Audit('rfa.cancel', 'rfa')
|
||||
async cancel(
|
||||
@Param('uuid', ParseUuidPipe) uuid: string,
|
||||
@CurrentUser() user: User
|
||||
@CurrentUser() user: User,
|
||||
@Headers('Idempotency-Key') idempotencyKey: string
|
||||
) {
|
||||
this.assertIdempotencyKey(idempotencyKey);
|
||||
return this.rfaService.cancel(uuid, user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
// File: src/modules/rfa/rfa.module.ts
|
||||
// Change Log:
|
||||
// - 2026-06-14: ตัด deprecated routing-template entities + RfaWorkflowService ออก (ADR-001 migration)
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
// Entities
|
||||
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
||||
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
|
||||
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
|
||||
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
||||
@@ -20,13 +19,9 @@ import { RfaItem } from './entities/rfa-item.entity';
|
||||
import { RfaRevision } from './entities/rfa-revision.entity';
|
||||
import { RfaStatusCode } from './entities/rfa-status-code.entity';
|
||||
import { RfaType } from './entities/rfa-type.entity';
|
||||
import { RfaWorkflowTemplateStep } from './entities/rfa-workflow-template-step.entity';
|
||||
import { RfaWorkflowTemplate } from './entities/rfa-workflow-template.entity';
|
||||
import { RfaWorkflow } from './entities/rfa-workflow.entity';
|
||||
import { Rfa } from './entities/rfa.entity';
|
||||
|
||||
// Services & Controllers
|
||||
import { RfaWorkflowService } from './rfa-workflow.service'; // Register Service
|
||||
import { RfaController } from './rfa.controller';
|
||||
import { RfaService } from './rfa.service';
|
||||
|
||||
@@ -55,12 +50,6 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
|
||||
AsBuiltDrawingRevision,
|
||||
ShopDrawingRevision,
|
||||
Discipline,
|
||||
RfaWorkflow,
|
||||
RfaWorkflowTemplate,
|
||||
RfaWorkflowTemplateStep,
|
||||
CorrespondenceRouting,
|
||||
RoutingTemplate,
|
||||
RoutingTemplateStep,
|
||||
CorrespondenceRecipient,
|
||||
Organization,
|
||||
]),
|
||||
@@ -72,7 +61,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
|
||||
WorkflowEngineModule,
|
||||
NotificationModule,
|
||||
],
|
||||
providers: [RfaService, RfaWorkflowService],
|
||||
providers: [RfaService],
|
||||
controllers: [RfaController],
|
||||
exports: [RfaService],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// File: src/modules/rfa/rfa.service.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Invoke TaskCreationService during submit when a review team is selected.
|
||||
// - 2026-06-14: ADR-001/021 migration — submit()/processAction() เดินผ่าน Unified Workflow Engine
|
||||
// (เลิกใช้ RoutingTemplate/CorrespondenceRouting), ตัด templateId, ย้าย notification ออกนอก transaction,
|
||||
// ทำ EC-RFA-001 ให้ race-safe (lock FOR UPDATE), เลิก hardcode approve code.
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
@@ -15,14 +18,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
|
||||
// Entities
|
||||
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
|
||||
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
|
||||
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
|
||||
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
|
||||
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
|
||||
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
@@ -49,6 +49,10 @@ type CorrRevWithRfa = CorrespondenceRevision & { rfaRevision?: RfaRevision };
|
||||
export interface RfaMapped extends Rfa {
|
||||
publicId?: string; // ADR-019: top-level publicId from correspondence
|
||||
revisions: CorrRevWithRfa[];
|
||||
// ADR-021: expose Unified Workflow Engine state for IntegratedBanner/WorkflowLifecycle
|
||||
workflowInstanceId?: string;
|
||||
workflowState?: string;
|
||||
availableActions?: string[];
|
||||
}
|
||||
|
||||
// Interfaces & Enums
|
||||
@@ -67,6 +71,20 @@ import { TaskCreationService } from '../review-team/services/task-creation.servi
|
||||
export class RfaService {
|
||||
private readonly logger = new Logger(RfaService.name);
|
||||
|
||||
/** ADR-001: รหัส Workflow ที่ลงทะเบียนใน seed DSL */
|
||||
static readonly WORKFLOW_CODE = 'RFA_APPROVAL';
|
||||
|
||||
/** แมป Workflow State → RFA Status Code ตาม seed data */
|
||||
static readonly STATE_TO_STATUS: Record<string, string> = {
|
||||
DRAFT: 'DFT',
|
||||
CONSULTANT_REVIEW: 'FRE',
|
||||
OWNER_REVIEW: 'FAP',
|
||||
APPROVED: 'FCO',
|
||||
};
|
||||
|
||||
/** รหัสอนุมัติเริ่มต้นเมื่อถึงสถานะ Terminal */
|
||||
static readonly DEFAULT_APPROVED_CODE = '1A';
|
||||
|
||||
private async hasSystemManageAllPermission(userId: number): Promise<boolean> {
|
||||
const permissions = await this.userService.getUserPermissions(userId);
|
||||
return permissions.includes('system.manage_all');
|
||||
@@ -99,12 +117,6 @@ export class RfaService {
|
||||
private asBuiltDrawingRevRepo: Repository<AsBuiltDrawingRevision>,
|
||||
@InjectRepository(ShopDrawingRevision)
|
||||
private shopDrawingRevRepo: Repository<ShopDrawingRevision>,
|
||||
@InjectRepository(CorrespondenceRouting)
|
||||
private routingRepo: Repository<CorrespondenceRouting>,
|
||||
@InjectRepository(RoutingTemplate)
|
||||
private templateRepo: Repository<RoutingTemplate>,
|
||||
@InjectRepository(RoutingTemplateStep)
|
||||
private templateStepRepo: Repository<RoutingTemplateStep>,
|
||||
@InjectRepository(Organization)
|
||||
private orgRepo: Repository<Organization>,
|
||||
|
||||
@@ -274,30 +286,6 @@ export class RfaService {
|
||||
);
|
||||
}
|
||||
|
||||
// EC-RFA-001: Check for existing active RFA per Shop Drawing Revision
|
||||
if (shopDrawingRevisionIds.length > 0) {
|
||||
const conflictingItems = await this.rfaItemRepo
|
||||
.createQueryBuilder('item')
|
||||
.innerJoin('item.rfaRevision', 'rfaRev')
|
||||
.innerJoin('rfaRev.statusCode', 'status')
|
||||
.where('item.shopDrawingRevisionId IN (:...ids)', {
|
||||
ids: shopDrawingRevisionIds,
|
||||
})
|
||||
.andWhere('status.statusCode NOT IN (:...codes)', {
|
||||
codes: ['CC', 'OBS'],
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (conflictingItems.length > 0) {
|
||||
throw new BusinessException(
|
||||
'EC_RFA_001_ACTIVE_RFA_EXISTS',
|
||||
'[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA.',
|
||||
'Shop Drawing Revision ที่เลือกมี RFA ที่ยังใช้งานอยู่แล้ว',
|
||||
['ตรวจสอบ RFA ที่มีอยู่', 'เลือก Shop Drawing Revision อื่น']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch real Organization Code for document numbering
|
||||
const userOrg = await this.orgRepo.findOne({
|
||||
where: { id: userOrgId },
|
||||
@@ -310,7 +298,42 @@ export class RfaService {
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// EC-RFA-001 (race-safe): ล็อกแถว shop_drawing_revisions ที่เลือกด้วย FOR UPDATE
|
||||
// ภายใน transaction เพื่อ serialize การสร้าง RFA พร้อมกันบน drawing เดียวกัน
|
||||
// จากนั้นค่อยตรวจว่ามี RFA ที่ยัง active อยู่หรือไม่ (กัน TOCTOU)
|
||||
if (shopDrawingRevisionIds.length > 0) {
|
||||
await queryRunner.manager.query(
|
||||
`SELECT id FROM shop_drawing_revisions WHERE id IN (${shopDrawingRevisionIds
|
||||
.map(() => '?')
|
||||
.join(',')}) FOR UPDATE`,
|
||||
shopDrawingRevisionIds
|
||||
);
|
||||
|
||||
const conflictingItems = await queryRunner.manager
|
||||
.createQueryBuilder(RfaItem, 'item')
|
||||
.innerJoin('item.rfaRevision', 'rfaRev')
|
||||
.innerJoin('rfaRev.statusCode', 'status')
|
||||
.where('item.shopDrawingRevisionId IN (:...ids)', {
|
||||
ids: shopDrawingRevisionIds,
|
||||
})
|
||||
.andWhere('status.statusCode NOT IN (:...codes)', {
|
||||
codes: ['CC', 'OBS'],
|
||||
})
|
||||
.getMany();
|
||||
|
||||
if (conflictingItems.length > 0) {
|
||||
throw new BusinessException(
|
||||
'EC_RFA_001_ACTIVE_RFA_EXISTS',
|
||||
'[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA.',
|
||||
'Shop Drawing Revision ที่เลือกมี RFA ที่ยังใช้งานอยู่แล้ว',
|
||||
['ตรวจสอบ RFA ที่มีอยู่', 'เลือก Shop Drawing Revision อื่น']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [UPDATED] Generate Document Number with Discipline
|
||||
// ADR-002: generateNextNumber ใช้ transaction ของตัวเอง (Redlock + optimistic counter)
|
||||
// จึงรับประกัน "ไม่ซ้ำ" แต่ "อาจมี gap" ได้หาก transaction นี้ rollback — ยอมรับได้ตาม ADR-002
|
||||
const docNumber = await this.numberingService.generateNextNumber({
|
||||
projectId: internalProjectId,
|
||||
originatorOrganizationId: userOrgId,
|
||||
@@ -443,11 +466,13 @@ export class RfaService {
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// [NEW V1.5.1] Start Unified Workflow Instance
|
||||
// [NEW V1.5.1] Start Unified Workflow Instance (ADR-001)
|
||||
// [C2 FIX] Drawing types (DDW/SDW/ADW) are RFA subtypes — all use RFA_APPROVAL
|
||||
// Instance ถูกสร้างใน state DRAFT; submit() จะ transition 'SUBMIT' ต่อ
|
||||
// หาก createInstance ล้มเหลว → log error (ไม่ fatal); submit() จะ self-heal สร้าง instance ให้
|
||||
try {
|
||||
await this.workflowEngine.createInstance(
|
||||
'RFA_APPROVAL',
|
||||
RfaService.WORKFLOW_CODE,
|
||||
'rfa',
|
||||
savedRfa.id.toString(),
|
||||
{
|
||||
@@ -459,8 +484,8 @@ export class RfaService {
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Workflow not started for ${docNumber.number}: ${(error as Error).message}`
|
||||
this.logger.error(
|
||||
`Workflow instance not started for ${docNumber.number} (will self-heal on submit): ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -495,8 +520,6 @@ export class RfaService {
|
||||
}
|
||||
}
|
||||
|
||||
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
|
||||
|
||||
async findAll(query: SearchRfaDto, _user?: User) {
|
||||
const {
|
||||
page = 1,
|
||||
@@ -552,11 +575,25 @@ export class RfaService {
|
||||
}
|
||||
|
||||
// RBAC: DFT documents are visible only to the originator org (spec §3.3.10)
|
||||
if (_user?.primaryOrganizationId) {
|
||||
queryBuilder.andWhere(
|
||||
'(rfaRev.id IS NULL OR status.statusCode != :dftCode OR corr.originatorId = :userOrgId)',
|
||||
{ dftCode: 'DFT', userOrgId: _user.primaryOrganizationId }
|
||||
// ผู้มีสิทธิ์ system.manage_all เห็นทุก DFT; ผู้อื่นเห็นเฉพาะของ org ตน
|
||||
// ผู้ที่ไม่มี org และไม่มีสิทธิ์ manage_all → ไม่เห็น DFT เลย (กัน data leak)
|
||||
if (_user) {
|
||||
const canViewAllDft = await this.hasSystemManageAllPermission(
|
||||
_user.user_id
|
||||
);
|
||||
if (!canViewAllDft) {
|
||||
if (_user.primaryOrganizationId) {
|
||||
queryBuilder.andWhere(
|
||||
'(rfaRev.id IS NULL OR status.statusCode != :dftCode OR corr.originatorId = :userOrgId)',
|
||||
{ dftCode: 'DFT', userOrgId: _user.primaryOrganizationId }
|
||||
);
|
||||
} else {
|
||||
queryBuilder.andWhere(
|
||||
'(rfaRev.id IS NULL OR status.statusCode != :dftCode)',
|
||||
{ dftCode: 'DFT' }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total] = await queryBuilder
|
||||
@@ -599,7 +636,7 @@ export class RfaService {
|
||||
* ADR-019: Find RFA by the parent Correspondence publicId (public identifier).
|
||||
* Resolves correspondence.publicId → internal rfa.id
|
||||
*/
|
||||
async findOneByUuid(publicId: string) {
|
||||
async findOneByUuid(publicId: string): Promise<RfaMapped> {
|
||||
const correspondence = await this.correspondenceRepo.findOne({
|
||||
where: { publicId },
|
||||
select: ['id'],
|
||||
@@ -607,7 +644,17 @@ export class RfaService {
|
||||
if (!correspondence) {
|
||||
throw new NotFoundException('RFA', publicId);
|
||||
}
|
||||
return this.findOne(correspondence.id);
|
||||
const mapped = (await this.findOne(correspondence.id)) as RfaMapped;
|
||||
|
||||
// ADR-021: ดึง Workflow Instance (nullable — DRAFT ที่ยังไม่เริ่ม submit ก็มี instance DRAFT)
|
||||
const wfInstance = await this.workflowEngine.getInstanceByEntity(
|
||||
'rfa',
|
||||
correspondence.id.toString()
|
||||
);
|
||||
mapped.workflowInstanceId = wfInstance?.id;
|
||||
mapped.workflowState = wfInstance?.currentState;
|
||||
mapped.availableActions = wfInstance?.availableActions ?? [];
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async findOneByUuidRaw(publicId: string) {
|
||||
@@ -668,11 +715,16 @@ export class RfaService {
|
||||
return mappedRfa;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit RFA เข้า Unified Workflow Engine (ADR-001)
|
||||
* - ใช้ Workflow Instance ที่สร้างไว้ตอน create() (state DRAFT); ถ้าไม่มีจะ self-heal
|
||||
* - Engine จัดการ Redlock + pessimistic lock + version CAS ป้องกัน double-submit
|
||||
*/
|
||||
async submit(
|
||||
rfaId: number,
|
||||
templateId: number,
|
||||
user: User,
|
||||
reviewTeamPublicId?: string
|
||||
reviewTeamPublicId?: string,
|
||||
roles: string[] = []
|
||||
) {
|
||||
const rfa = await this.findOne(rfaId, true);
|
||||
const corrRevisions =
|
||||
@@ -692,109 +744,93 @@ export class RfaService {
|
||||
);
|
||||
}
|
||||
|
||||
const template = await this.templateRepo.findOne({
|
||||
where: { id: templateId },
|
||||
// relations: ['steps'], // Deprecated relation removed
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new BusinessException(
|
||||
'ROUTING_TEMPLATE_NOT_FOUND',
|
||||
'Invalid routing template',
|
||||
'ไม่พบ Routing Template ที่กำหนด',
|
||||
['ตรวจสอบ Routing Template ที่ตั้งค่าไว้']
|
||||
// ADR-001: หา Workflow Instance ที่สร้างไว้ตอน create() — ถ้าไม่มีให้ self-heal
|
||||
let instance = await this.workflowEngine.getInstanceByEntity(
|
||||
'rfa',
|
||||
rfaId.toString()
|
||||
);
|
||||
if (instance && instance.currentState !== 'DRAFT') {
|
||||
throw new WorkflowException(
|
||||
'RFA_ALREADY_SUBMITTED',
|
||||
`RFA already submitted (state: ${instance.currentState})`,
|
||||
'RFA นี้ถูกส่งเข้า Workflow ไปแล้ว',
|
||||
['รีเฟรชหน้าเพื่อดูสถานะล่าสุด']
|
||||
);
|
||||
}
|
||||
|
||||
// Manual fetch of steps
|
||||
const steps = await this.templateStepRepo.find({
|
||||
where: { templateId: template.id },
|
||||
order: { sequence: 'ASC' },
|
||||
});
|
||||
|
||||
if (steps.length === 0) {
|
||||
throw new BusinessException(
|
||||
'ROUTING_TEMPLATE_EMPTY',
|
||||
'Routing template has no steps',
|
||||
'Routing Template ไม่มีขั้นตอนกำหนดไว้',
|
||||
['เพิ่ม Step ใน Routing Template']
|
||||
if (!instance) {
|
||||
const created = await this.workflowEngine.createInstance(
|
||||
RfaService.WORKFLOW_CODE,
|
||||
'rfa',
|
||||
rfaId.toString(),
|
||||
{
|
||||
projectId: rfa.correspondence.projectId,
|
||||
originatorId: rfa.correspondence.originatorId,
|
||||
disciplineId: rfa.correspondence.disciplineId,
|
||||
initiatorId: user.user_id,
|
||||
}
|
||||
);
|
||||
instance = {
|
||||
id: created.id,
|
||||
currentState: created.currentState,
|
||||
availableActions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const statusForApprove = await this.rfaStatusRepo.findOne({
|
||||
where: { statusCode: 'FAP' },
|
||||
});
|
||||
if (!statusForApprove)
|
||||
throw new SystemException('Status FAP not found in Master Data');
|
||||
// ADR-001: transition 'SUBMIT' (DSL ต้องการ role CONTRACTOR ผ่าน context.roles)
|
||||
const result = await this.workflowEngine.processTransition(
|
||||
instance.id,
|
||||
'SUBMIT',
|
||||
user.user_id,
|
||||
'RFA Submitted',
|
||||
{ roles },
|
||||
undefined,
|
||||
user.publicId
|
||||
);
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
// Sync สถานะ RFA Revision ตาม state ใหม่ (เช่น CONSULTANT_REVIEW → FRE)
|
||||
await this.syncRevisionStatus(currentRfaRev, result.nextState);
|
||||
currentCorrRev.issuedDate = new Date();
|
||||
await this.corrRevRepo.save(currentCorrRev);
|
||||
|
||||
try {
|
||||
// Update Revision Status
|
||||
currentRfaRev.rfaStatusCodeId = statusForApprove.id;
|
||||
currentCorrRev.issuedDate = new Date();
|
||||
await queryRunner.manager.save(currentRfaRev);
|
||||
await queryRunner.manager.save(currentCorrRev);
|
||||
|
||||
// Create First Routing Step
|
||||
const firstStep = steps[0];
|
||||
const routing = queryRunner.manager.create(CorrespondenceRouting, {
|
||||
correspondenceId: rfa.correspondence.id, // ✅ Use master correspondence id
|
||||
templateId: template.id,
|
||||
sequence: 1,
|
||||
fromOrganizationId: user.primaryOrganizationId,
|
||||
toOrganizationId: firstStep.toOrganizationId,
|
||||
stepPurpose: firstStep.stepPurpose,
|
||||
status: 'SENT',
|
||||
dueDate: new Date(
|
||||
Date.now() + (firstStep.expectedDays || 7) * 24 * 60 * 60 * 1000
|
||||
),
|
||||
processedByUserId: user.user_id,
|
||||
processedAt: new Date(),
|
||||
});
|
||||
await queryRunner.manager.save(routing);
|
||||
|
||||
if (reviewTeamPublicId) {
|
||||
// FR-003: สร้าง Parallel Review Tasks (ถ้าเลือก Review Team) — transaction ของตัวเอง
|
||||
if (reviewTeamPublicId) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.taskCreationService.createParallelTasks(
|
||||
currentRfaRev.id,
|
||||
currentCorrRev.publicId, // ADR-019: Pass UUID
|
||||
reviewTeamPublicId,
|
||||
routing.dueDate ?? new Date(),
|
||||
queryRunner.manager,
|
||||
currentCorrRev.dueDate ?? new Date(),
|
||||
manager,
|
||||
rfa.correspondence.projectId,
|
||||
rfa.rfaType.typeCode
|
||||
);
|
||||
}
|
||||
|
||||
// Notify
|
||||
const recipientUserId = await this.userService.findDocControlIdByOrg(
|
||||
firstStep.toOrganizationId
|
||||
);
|
||||
if (recipientUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: recipientUserId,
|
||||
title: `RFA Submitted: ${currentCorrRev.subject}`,
|
||||
message: `RFA ${rfa.correspondence.correspondenceNumber} submitted for approval.`,
|
||||
type: 'SYSTEM',
|
||||
entityType: 'rfa',
|
||||
entityId: rfa.id,
|
||||
});
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: 'RFA Submitted successfully', routing };
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
});
|
||||
}
|
||||
|
||||
// ADR-008: แจ้งเตือนแบบ fire-and-forget หลังทุกอย่างสำเร็จ (ไม่ค้างใน transaction)
|
||||
void this.notifyRecipients(
|
||||
rfa.correspondence.id,
|
||||
rfa.correspondence.correspondenceNumber,
|
||||
currentCorrRev.subject
|
||||
).catch((err: unknown) =>
|
||||
this.logger.warn(
|
||||
`RFA submit notification failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
);
|
||||
|
||||
return { instanceId: instance.id, currentState: result.nextState };
|
||||
}
|
||||
|
||||
async processAction(rfaId: number, dto: WorkflowActionDto, user: User) {
|
||||
// Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB
|
||||
/**
|
||||
* ดำเนินการ Workflow Action (APPROVE/REJECT) ผ่าน Unified Workflow Engine (ADR-001)
|
||||
* Engine จัดการ pessimistic lock + version CAS ป้องกัน double-approval / TOCTOU
|
||||
*/
|
||||
async processAction(
|
||||
rfaId: number,
|
||||
dto: WorkflowActionDto,
|
||||
user: User,
|
||||
roles: string[] = []
|
||||
) {
|
||||
const rfa = await this.findOne(rfaId, true);
|
||||
const corrRevisions =
|
||||
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
|
||||
@@ -804,118 +840,107 @@ export class RfaService {
|
||||
|
||||
const currentRfaRev = currentCorrRev.rfaRevision;
|
||||
|
||||
const currentRouting = await this.routingRepo.findOne({
|
||||
where: {
|
||||
correspondenceId: rfa.correspondence.id, // ✅ Use master correspondence id
|
||||
status: 'SENT',
|
||||
},
|
||||
order: { sequence: 'DESC' },
|
||||
relations: ['toOrganization'],
|
||||
});
|
||||
|
||||
if (!currentRouting)
|
||||
const instance = await this.workflowEngine.getInstanceByEntity(
|
||||
'rfa',
|
||||
rfaId.toString()
|
||||
);
|
||||
if (!instance) {
|
||||
throw new WorkflowException(
|
||||
'NO_ACTIVE_WORKFLOW_STEP',
|
||||
'No active workflow step found',
|
||||
'ไม่พบขั้นตอน Workflow ที่ยังเปิดอยู่',
|
||||
'No active workflow instance found',
|
||||
'ไม่พบ Workflow ที่ยังเปิดอยู่',
|
||||
['ตรวจสอบสถานะ Workflow ของเอกสาร']
|
||||
);
|
||||
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
|
||||
throw new PermissionException('rfa workflow step', 'process');
|
||||
}
|
||||
|
||||
const template = await this.templateRepo.findOne({
|
||||
where: { id: currentRouting.templateId },
|
||||
// relations: ['steps'],
|
||||
});
|
||||
const approveCodeStr =
|
||||
typeof dto.payload?.approveCode === 'string'
|
||||
? dto.payload.approveCode
|
||||
: undefined;
|
||||
|
||||
if (!template)
|
||||
throw new SystemException(
|
||||
'Routing Template not found for workflow processing'
|
||||
);
|
||||
|
||||
// Manual fetch steps
|
||||
const steps = await this.templateStepRepo.find({
|
||||
where: { templateId: template.id },
|
||||
order: { sequence: 'ASC' },
|
||||
});
|
||||
|
||||
if (steps.length === 0)
|
||||
throw new SystemException('Routing Template steps not found');
|
||||
|
||||
// Call Engine to calculate next step
|
||||
const result = this.workflowEngine.processAction(
|
||||
currentRouting.sequence,
|
||||
steps.length,
|
||||
const result = await this.workflowEngine.processTransition(
|
||||
instance.id,
|
||||
dto.action,
|
||||
dto.returnToSequence
|
||||
user.user_id,
|
||||
dto.comment ?? dto.comments,
|
||||
{ roles, approveCode: approveCodeStr },
|
||||
undefined,
|
||||
user.publicId
|
||||
);
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
// Sync RFA Revision status + approve code ตาม state ใหม่
|
||||
await this.syncRevisionStatus(
|
||||
currentRfaRev,
|
||||
result.nextState,
|
||||
dto.action === WorkflowAction.REJECT ? undefined : approveCodeStr,
|
||||
result.isCompleted
|
||||
);
|
||||
|
||||
try {
|
||||
// Update current routing
|
||||
currentRouting.status =
|
||||
dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
|
||||
currentRouting.processedByUserId = user.user_id;
|
||||
currentRouting.processedAt = new Date();
|
||||
currentRouting.comments = dto.comments;
|
||||
await queryRunner.manager.save(currentRouting);
|
||||
return { message: 'Action processed', result };
|
||||
}
|
||||
|
||||
// Create next routing if available
|
||||
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
|
||||
const nextStep = steps.find(
|
||||
(s) => s.sequence === result.nextStepSequence
|
||||
);
|
||||
if (nextStep) {
|
||||
const nextRouting = queryRunner.manager.create(
|
||||
CorrespondenceRouting,
|
||||
{
|
||||
correspondenceId: rfa.correspondence.id, // ✅ Use master correspondence id
|
||||
templateId: template.id,
|
||||
sequence: result.nextStepSequence,
|
||||
fromOrganizationId: user.primaryOrganizationId,
|
||||
toOrganizationId: nextStep.toOrganizationId,
|
||||
stepPurpose: nextStep.stepPurpose,
|
||||
status: 'SENT',
|
||||
dueDate: new Date(
|
||||
Date.now() + (nextStep.expectedDays || 7) * 24 * 60 * 60 * 1000
|
||||
),
|
||||
}
|
||||
);
|
||||
await queryRunner.manager.save(nextRouting);
|
||||
}
|
||||
} else if (result.nextStepSequence === null) {
|
||||
// Workflow Ended (Completed or Rejected)
|
||||
// Update RFA Status (Approved/Rejected Code)
|
||||
if (dto.action !== WorkflowAction.REJECT) {
|
||||
const approveCode = await this.rfaApproveRepo.findOne({
|
||||
where: {
|
||||
approveCode: dto.action === WorkflowAction.APPROVE ? '1A' : '4X',
|
||||
},
|
||||
}); // Logic Map Code อย่างง่าย
|
||||
if (approveCode) {
|
||||
currentRfaRev.rfaApproveCodeId = approveCode.id;
|
||||
currentRfaRev.approvedDate = new Date();
|
||||
}
|
||||
} else {
|
||||
const rejectCode = await this.rfaApproveRepo.findOne({
|
||||
where: { approveCode: '4X' },
|
||||
});
|
||||
if (rejectCode) currentRfaRev.rfaApproveCodeId = rejectCode.id;
|
||||
}
|
||||
await queryRunner.manager.save(currentRfaRev);
|
||||
/**
|
||||
* Helper: Map Workflow State → RFA Status Code (+ Approve Code เมื่อถึง Terminal) แล้วบันทึก
|
||||
* เลิก hardcode magic string — ใช้ STATE_TO_STATUS map + payload override
|
||||
*/
|
||||
private async syncRevisionStatus(
|
||||
revision: RfaRevision,
|
||||
workflowState: string,
|
||||
approveCodeStr?: string,
|
||||
isTerminalApproved = false
|
||||
): Promise<void> {
|
||||
const targetStatusCode = RfaService.STATE_TO_STATUS[workflowState] ?? 'DFT';
|
||||
const status = await this.rfaStatusRepo.findOne({
|
||||
where: { statusCode: targetStatusCode },
|
||||
});
|
||||
if (status) {
|
||||
revision.rfaStatusCodeId = status.id;
|
||||
}
|
||||
|
||||
if (isTerminalApproved) {
|
||||
const codeToUse = approveCodeStr ?? RfaService.DEFAULT_APPROVED_CODE;
|
||||
const approveCode = await this.rfaApproveRepo.findOne({
|
||||
where: { approveCode: codeToUse },
|
||||
});
|
||||
if (approveCode) {
|
||||
revision.rfaApproveCodeId = approveCode.id;
|
||||
revision.approvedDate = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: 'Action processed', result };
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
await this.rfaRevisionRepo.save(revision);
|
||||
this.logger.log(
|
||||
`Synced RFA Revision ${revision.id}: state=${workflowState} → status=${targetStatusCode}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-008: แจ้งเตือน Document Controller ของ org ผู้รับ (TO) แบบ async
|
||||
* เรียกหลัง transaction สำเร็จเท่านั้น (ห้ามค้างใน request transaction)
|
||||
*/
|
||||
private async notifyRecipients(
|
||||
correspondenceId: number,
|
||||
correspondenceNumber: string,
|
||||
subject?: string
|
||||
): Promise<void> {
|
||||
const recipients = await this.dataSource.manager.find(
|
||||
CorrespondenceRecipient,
|
||||
{ where: { correspondenceId, recipientType: 'TO' } }
|
||||
);
|
||||
for (const r of recipients) {
|
||||
const targetUserId = await this.userService.findDocControlIdByOrg(
|
||||
r.recipientOrganizationId
|
||||
);
|
||||
if (targetUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: targetUserId,
|
||||
title: `RFA Submitted: ${subject ?? correspondenceNumber}`,
|
||||
message: `RFA ${correspondenceNumber} submitted for approval.`,
|
||||
type: 'SYSTEM',
|
||||
entityType: 'rfa',
|
||||
entityId: correspondenceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
// File: backend/tests/integration/modules/ai/ai-policy.service.integration.spec.ts
|
||||
// Change Log:
|
||||
// - 2026-06-13: T034 — Integration test สำหรับ apply flow (sandbox draft → validate → production + cache DEL)
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { AiPolicyService } from '../../../../src/modules/ai/services/ai-policy.service';
|
||||
import { AiExecutionProfile } from '../../../../src/modules/ai/entities/ai-execution-profile.entity';
|
||||
import { AiSandboxProfile } from '../../../../src/modules/ai/entities/ai-sandbox-profile.entity';
|
||||
|
||||
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
||||
|
||||
/**
|
||||
* Integration test สำหรับ Apply Profile Flow (T034 — ADR-036)
|
||||
*
|
||||
* ครอบคลุม cross-service interactions:
|
||||
* 1. Full apply flow: sandbox draft → validation → copy to production → Redis cache DEL
|
||||
* 2. Idempotency logic: duplicate key ใน Redis ต้องไม่ apply ซ้ำ
|
||||
* 3. Parameter range validation propagation
|
||||
* 4. Cache miss → DB fallback → cache set → subsequent cache hit
|
||||
*/
|
||||
describe('AiPolicyService — Apply Flow Integration (T034)', () => {
|
||||
let service: AiPolicyService;
|
||||
|
||||
const productionRow = {
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai' as const,
|
||||
isActive: true,
|
||||
temperature: 0.4,
|
||||
topP: 0.85,
|
||||
maxTokens: 3000,
|
||||
numCtx: 6000,
|
||||
repeatPenalty: 1.2,
|
||||
keepAliveSeconds: 300,
|
||||
updatedBy: undefined as number | undefined,
|
||||
};
|
||||
|
||||
const sandboxDraft = {
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai' as const,
|
||||
temperature: 0.65,
|
||||
topP: 0.9,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
};
|
||||
|
||||
let mockProfileRepo: {
|
||||
findOne: jest.Mock;
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
|
||||
let mockSandboxProfileRepo: {
|
||||
findOne: jest.Mock;
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
|
||||
let mockRedis: {
|
||||
get: jest.Mock;
|
||||
set: jest.Mock;
|
||||
del: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const savedProductionRow = { ...productionRow };
|
||||
mockProfileRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ ...savedProductionRow }),
|
||||
create: jest.fn((input: unknown) => ({ ...(input as object) })),
|
||||
save: jest.fn((input: unknown) => {
|
||||
Object.assign(savedProductionRow, input as object);
|
||||
return Promise.resolve({ ...savedProductionRow });
|
||||
}),
|
||||
};
|
||||
mockSandboxProfileRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ ...sandboxDraft }),
|
||||
create: jest.fn((input: unknown) => ({ ...(input as object) })),
|
||||
save: jest.fn((input: unknown) =>
|
||||
Promise.resolve({ ...(input as object) })
|
||||
),
|
||||
};
|
||||
mockRedis = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue('OK'),
|
||||
del: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiPolicyService,
|
||||
{
|
||||
provide: getRepositoryToken(AiExecutionProfile),
|
||||
useValue: mockProfileRepo,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AiSandboxProfile),
|
||||
useValue: mockSandboxProfileRepo,
|
||||
},
|
||||
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<AiPolicyService>(AiPolicyService);
|
||||
});
|
||||
|
||||
describe('Full apply flow: draft → validate → production → cache DEL', () => {
|
||||
it('ควรคัดลอกค่าจาก sandbox draft ไปยัง production row และลบ Redis cache ทั้งสองคีย์', async () => {
|
||||
const result = await service.applyProfile('standard', 42);
|
||||
|
||||
expect(mockSandboxProfileRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { profileName: 'standard' },
|
||||
});
|
||||
expect(mockProfileRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { profileName: 'standard' },
|
||||
});
|
||||
|
||||
expect(mockProfileRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 0.65,
|
||||
topP: 0.9,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
updatedBy: 42,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'ai_execution_profiles:standard'
|
||||
);
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'ai_execution_profiles:model:np-dms-ai'
|
||||
);
|
||||
|
||||
expect(result.temperature).toBe(0.65);
|
||||
expect(result.topP).toBe(0.9);
|
||||
expect(result.keepAliveSeconds).toBe(600);
|
||||
});
|
||||
|
||||
it('ควรสร้าง production row ใหม่หากยังไม่มีอยู่ใน DB', async () => {
|
||||
mockProfileRepo.findOne.mockResolvedValue(null);
|
||||
mockProfileRepo.create.mockImplementation((input: unknown) => ({
|
||||
...(input as object),
|
||||
}));
|
||||
mockProfileRepo.save.mockImplementation((input: unknown) =>
|
||||
Promise.resolve({ ...(input as object) })
|
||||
);
|
||||
|
||||
const result = await service.applyProfile('standard', 1);
|
||||
|
||||
expect(mockProfileRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profileName: 'standard', isActive: true })
|
||||
);
|
||||
expect(result.temperature).toBe(sandboxDraft.temperature);
|
||||
});
|
||||
|
||||
it('ควรยังคง apply ได้แม้ Redis DEL ล้มเหลว (cache failure tolerant)', async () => {
|
||||
mockRedis.del.mockRejectedValue(new Error('Redis connection lost'));
|
||||
|
||||
const result = await service.applyProfile('standard', 7);
|
||||
|
||||
expect(result.temperature).toBe(sandboxDraft.temperature);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotFoundException เมื่อไม่มี sandbox draft', () => {
|
||||
it('ควรโยน NotFoundException เมื่อ sandbox draft ไม่มีอยู่ใน DB', async () => {
|
||||
mockSandboxProfileRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.applyProfile('standard')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter range validation propagation', () => {
|
||||
const makeInvalidDraft = (
|
||||
overrides: Partial<typeof sandboxDraft>
|
||||
): unknown => ({
|
||||
...sandboxDraft,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it.each([
|
||||
['temperature เกิน 1', { temperature: 1.01 }],
|
||||
['temperature ต่ำกว่า 0', { temperature: -0.01 }],
|
||||
['topP เกิน 1', { topP: 1.1 }],
|
||||
['topP ต่ำกว่า 0', { topP: -0.1 }],
|
||||
['repeatPenalty ต่ำกว่า 1', { repeatPenalty: 0.99 }],
|
||||
['repeatPenalty เกิน 2', { repeatPenalty: 2.01 }],
|
||||
['keepAliveSeconds ติดลบ', { keepAliveSeconds: -1 }],
|
||||
])('ควรโยน BadRequestException เมื่อ %s', async (_label, invalidValue) => {
|
||||
mockSandboxProfileRepo.findOne.mockResolvedValue(
|
||||
makeInvalidDraft(invalidValue)
|
||||
);
|
||||
|
||||
await expect(service.applyProfile('standard')).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache lifecycle หลัง apply', () => {
|
||||
it('ควรให้ cache miss หลัง apply เพื่อบังคับ fresh read จาก DB รอบถัดไป', async () => {
|
||||
await service.applyProfile('standard', 1);
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledTimes(2);
|
||||
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue({
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai',
|
||||
isActive: true,
|
||||
temperature: 0.65,
|
||||
topP: 0.9,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
});
|
||||
|
||||
const freshParams = await service.getProfileParameters('standard');
|
||||
expect(freshParams.temperature).toBe(0.65);
|
||||
expect(mockProfileRepo.findOne).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ควรเขียน cache ใหม่หลัง getProfileParameters อ่านจาก DB', async () => {
|
||||
await service.applyProfile('standard', 1);
|
||||
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockProfileRepo.findOne.mockResolvedValue({
|
||||
profileName: 'standard',
|
||||
canonicalModel: 'np-dms-ai',
|
||||
isActive: true,
|
||||
temperature: 0.65,
|
||||
topP: 0.9,
|
||||
maxTokens: 4096,
|
||||
numCtx: 8192,
|
||||
repeatPenalty: 1.15,
|
||||
keepAliveSeconds: 600,
|
||||
});
|
||||
|
||||
await service.getProfileParameters('standard');
|
||||
|
||||
expect(mockRedis.set).toHaveBeenCalledWith(
|
||||
'ai_execution_profiles:standard',
|
||||
expect.stringContaining('"temperature":0.65'),
|
||||
'EX',
|
||||
60
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dual-model: apply ของ OCR profile', () => {
|
||||
it('ควรลบ model cache key ของ np-dms-ocr เมื่อ apply ocr-extract profile', async () => {
|
||||
const ocrDraft = {
|
||||
profileName: 'ocr-extract',
|
||||
canonicalModel: 'np-dms-ocr' as const,
|
||||
temperature: 0.12,
|
||||
topP: 0.18,
|
||||
maxTokens: null,
|
||||
numCtx: null,
|
||||
repeatPenalty: 1.05,
|
||||
keepAliveSeconds: 0,
|
||||
};
|
||||
mockSandboxProfileRepo.findOne.mockResolvedValue(ocrDraft);
|
||||
mockProfileRepo.findOne.mockResolvedValue({
|
||||
profileName: 'ocr-extract',
|
||||
canonicalModel: 'np-dms-ocr',
|
||||
isActive: true,
|
||||
...ocrDraft,
|
||||
});
|
||||
mockProfileRepo.save.mockImplementation((input: unknown) =>
|
||||
Promise.resolve({ ...(input as object) })
|
||||
);
|
||||
|
||||
await service.applyProfile('ocr-extract', 5);
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'ai_execution_profiles:ocr-extract'
|
||||
);
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(
|
||||
'ai_execution_profiles:model:np-dms-ocr'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user