feat(ai): implement unified prompt management UX/UI (ADR-037)
CI / CD Pipeline / build (push) Failing after 3m23s
CI / CD Pipeline / deploy (push) Has been skipped

- 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:
2026-06-14 19:55:43 +07:00
parent 56f9544cb0
commit 67da186672
64 changed files with 6327 additions and 6107 deletions
+3 -1
View File
@@ -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;
+29
View File
@@ -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',
+45 -8
View File
@@ -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);
}
}
+3 -14
View File
@@ -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],
})
+256 -231
View File
@@ -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,
});
}
}
}