251124:1700 Ready to Phase 7

This commit is contained in:
admin
2025-11-24 17:01:58 +07:00
parent 9360d78ea6
commit 4f45a69ed0
47 changed files with 2047 additions and 433 deletions

View File

@@ -0,0 +1,19 @@
// File: src/modules/master/dto/create-tag.dto.ts
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTagDto {
@ApiProperty({ example: 'URGENT', description: 'ชื่อ Tag' })
@IsString()
@IsNotEmpty()
tag_name: string;
@ApiProperty({
example: 'เอกสารด่วนต้องดำเนินการทันที',
description: 'คำอธิบาย',
})
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,26 @@
// File: src/modules/master/dto/search-tag.dto.ts
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class SearchTagDto {
@ApiPropertyOptional({ description: 'คำค้นหา (ชื่อ Tag หรือ คำอธิบาย)' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'หมายเลขหน้า (เริ่มต้น 1)', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'จำนวนรายการต่อหน้า', default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 20;
}

View File

@@ -0,0 +1,6 @@
// File: src/modules/master/dto/update-tag.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateTagDto } from './create-tag.dto';
export class UpdateTagDto extends PartialType(CreateTagDto) {}

View File

@@ -0,0 +1,27 @@
// File: src/modules/master/entities/tag.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('tags')
export class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100, unique: true, comment: 'ชื่อ Tag' })
tag_name: string;
@Column({ type: 'text', nullable: true, comment: 'คำอธิบายแท็ก' })
description: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}

View File

@@ -0,0 +1,64 @@
// File: src/modules/master/master.controller.ts
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MasterService } from './master.service';
import { CreateTagDto } from './dto/create-tag.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Master Data')
@Controller('master')
@UseGuards(JwtAuthGuard) // บังคับ Login ทุก Endpoint
export class MasterController {
constructor(private readonly masterService: MasterService) {}
@Get('correspondence-types')
@ApiOperation({ summary: 'Get all active correspondence types' })
getCorrespondenceTypes() {
return this.masterService.findAllCorrespondenceTypes();
}
@Get('correspondence-statuses')
@ApiOperation({ summary: 'Get all active correspondence statuses' })
getCorrespondenceStatuses() {
return this.masterService.findAllCorrespondenceStatuses();
}
@Get('rfa-types')
@ApiOperation({ summary: 'Get all active RFA types' })
getRfaTypes() {
return this.masterService.findAllRfaTypes();
}
@Get('rfa-statuses')
@ApiOperation({ summary: 'Get all active RFA status codes' })
getRfaStatuses() {
return this.masterService.findAllRfaStatuses();
}
@Get('rfa-approve-codes')
@ApiOperation({ summary: 'Get all active RFA approve codes' })
getRfaApproveCodes() {
return this.masterService.findAllRfaApproveCodes();
}
@Get('circulation-statuses')
@ApiOperation({ summary: 'Get all active circulation statuses' })
getCirculationStatuses() {
return this.masterService.findAllCirculationStatuses();
}
@Get('tags')
@ApiOperation({ summary: 'Get all tags' })
getTags() {
return this.masterService.findAllTags();
}
@Post('tags')
@RequirePermission('master_data.tag.manage') // ต้องมีสิทธิ์จัดการ Tag
@ApiOperation({ summary: 'Create a new tag (Admin only)' })
createTag(@Body() dto: CreateTagDto) {
return this.masterService.createTag(dto);
}
}

View File

@@ -0,0 +1,33 @@
// File: src/modules/master/master.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MasterService } from './master.service';
import { MasterController } from './master.controller';
// Import Entities
import { Tag } from './entities/tag.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { RfaType } from '../rfa/entities/rfa-type.entity';
import { RfaStatusCode } from '../rfa/entities/rfa-status-code.entity';
import { RfaApproveCode } from '../rfa/entities/rfa-approve-code.entity';
import { CirculationStatusCode } from '../circulation/entities/circulation-status-code.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Tag,
CorrespondenceType,
CorrespondenceStatus,
RfaType,
RfaStatusCode,
RfaApproveCode,
CirculationStatusCode,
]),
],
controllers: [MasterController],
providers: [MasterService],
exports: [MasterService], // Export เผื่อ Module อื่นต้องใช้
})
export class MasterModule {}

View File

@@ -0,0 +1,97 @@
// File: src/modules/master/master.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
// Import Entities จาก Module อื่นๆ (ตามโครงสร้างที่มีอยู่แล้ว)
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { RfaType } from '../rfa/entities/rfa-type.entity';
import { RfaStatusCode } from '../rfa/entities/rfa-status-code.entity';
import { RfaApproveCode } from '../rfa/entities/rfa-approve-code.entity';
import { CirculationStatusCode } from '../circulation/entities/circulation-status-code.entity';
import { Tag } from './entities/tag.entity'; // Entity ของ Module นี้เอง
import { CreateTagDto } from './dto/create-tag.dto';
@Injectable()
export class MasterService {
constructor(
@InjectRepository(CorrespondenceType)
private readonly corrTypeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
private readonly corrStatusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(RfaType)
private readonly rfaTypeRepo: Repository<RfaType>,
@InjectRepository(RfaStatusCode)
private readonly rfaStatusRepo: Repository<RfaStatusCode>,
@InjectRepository(RfaApproveCode)
private readonly rfaApproveRepo: Repository<RfaApproveCode>,
@InjectRepository(CirculationStatusCode)
private readonly circulationStatusRepo: Repository<CirculationStatusCode>,
@InjectRepository(Tag)
private readonly tagRepo: Repository<Tag>,
) {}
// --- Correspondence ---
findAllCorrespondenceTypes() {
return this.corrTypeRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
findAllCorrespondenceStatuses() {
return this.corrStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
// --- RFA ---
findAllRfaTypes() {
return this.rfaTypeRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
findAllRfaStatuses() {
return this.rfaStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
findAllRfaApproveCodes() {
return this.rfaApproveRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
// --- Circulation ---
findAllCirculationStatuses() {
return this.circulationStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
});
}
// --- Tags ---
findAllTags() {
return this.tagRepo.find({ order: { tag_name: 'ASC' } });
}
async createTag(dto: CreateTagDto) {
const tag = this.tagRepo.create(dto);
return this.tagRepo.save(tag);
}
}

View File

@@ -0,0 +1,45 @@
// File: src/modules/monitoring/controllers/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HttpHealthIndicator,
HealthCheck,
TypeOrmHealthIndicator,
MemoryHealthIndicator,
DiskHealthIndicator,
} from '@nestjs/terminus';
import { MetricsService } from '../services/metrics.service';
@Controller()
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private db: TypeOrmHealthIndicator,
private memory: MemoryHealthIndicator,
private disk: DiskHealthIndicator,
private metricsService: MetricsService,
) {}
@Get('health')
@HealthCheck()
check() {
return this.health.check([
// 1. ตรวจสอบการเชื่อมต่อ Database (MariaDB)
() => this.db.pingCheck('database'),
// 2. ตรวจสอบ Memory Heap (ไม่ควรเกิน 1GB สำหรับ Container นี้ - ปรับค่าตามจริง)
() => this.memory.checkHeap('memory_heap', 1024 * 1024 * 1024),
// 3. ตรวจสอบพื้นที่ Disk สำหรับ DMS Data (Threshold 90%)
// path '/' อาจต้องเปลี่ยนเป็น '/share/dms-data' ตาม Environment จริง
() =>
this.disk.checkStorage('storage', { path: '/', thresholdPercent: 0.9 }),
]);
}
@Get('metrics')
async getMetrics() {
return await this.metricsService.getMetrics();
}
}

View File

@@ -0,0 +1,30 @@
// File: src/modules/monitoring/logger/winston.config.ts
import {
utilities as nestWinstonUtilities,
WinstonModuleOptions,
} from 'nest-winston';
import * as winston from 'winston';
/**
* ฟังก์ชันสร้าง Configuration สำหรับ Winston Logger
* - Development: แสดงผลแบบ Console อ่านง่าย
* - Production: แสดงผลแบบ JSON พร้อม Timestamp เพื่อการทำ Log Aggregation
*/
export const winstonConfig: WinstonModuleOptions = {
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
// เลือก Format ตาม Environment
process.env.NODE_ENV === 'production'
? winston.format.json() // Production ใช้ JSON
: nestWinstonUtilities.format.nestLike('LCBP3-DMS', {
prettyPrint: true,
colors: true,
}),
),
}),
// สามารถเพิ่ม File Transport หรือ HTTP Transport ไปยัง Log Server ได้ที่นี่
],
};

View File

@@ -0,0 +1,23 @@
// File: src/modules/monitoring/monitoring.module.ts
import { Global, Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { HealthController } from './controllers/health.controller';
import { MetricsService } from './services/metrics.service';
import { PerformanceInterceptor } from '../../common/interceptors/performance.interceptor';
@Global() // ทำให้ Module นี้ใช้งานได้ทั่วทั้ง App โดยไม่ต้อง Import ซ้ำ
@Module({
imports: [TerminusModule, HttpModule],
controllers: [HealthController],
providers: [
MetricsService,
{
provide: APP_INTERCEPTOR, // Register Global Interceptor
useClass: PerformanceInterceptor,
},
],
exports: [MetricsService],
})
export class MonitoringModule {}

View File

@@ -0,0 +1,54 @@
// File: src/modules/monitoring/services/metrics.service.ts
import { Injectable } from '@nestjs/common';
import { Registry, Counter, Histogram, Gauge } from 'prom-client';
@Injectable()
export class MetricsService {
private readonly registry: Registry;
public readonly httpRequestsTotal: Counter<string>;
public readonly httpRequestDuration: Histogram<string>;
public readonly systemMemoryUsage: Gauge<string>;
constructor() {
this.registry = new Registry();
this.registry.setDefaultLabels({ app: 'lcbp3-backend' });
// นับจำนวน HTTP Request ทั้งหมด แยกตาม method, route, status_code
this.httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
registers: [this.registry],
});
// วัดระยะเวลา Response Time (Histogram)
this.httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.1, 0.2, 0.5, 1.0, 1.5, 2.0, 5.0], // Buckets สำหรับวัด Latency
registers: [this.registry],
});
// วัดการใช้ Memory (Gauge)
this.systemMemoryUsage = new Gauge({
name: 'system_memory_usage_bytes',
help: 'Heap memory usage in bytes',
registers: [this.registry],
});
// เริ่มเก็บ Metrics พื้นฐานของ Node.js (Optional)
// client.collectDefaultMetrics({ register: this.registry });
}
/**
* ดึงข้อมูล Metrics ทั้งหมดในรูปแบบ Text สำหรับ Prometheus Scrape
*/
async getMetrics(): Promise<string> {
// อัปเดต Memory Usage ก่อน Return
const memoryUsage = process.memoryUsage();
this.systemMemoryUsage.set(memoryUsage.heapUsed);
return this.registry.metrics();
}
}

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
// File: src/modules/notification/notification.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { InjectRepository } from '@nestjs/typeorm';
@@ -12,15 +13,18 @@ import { UserPreference } from '../user/entities/user-preference.entity';
// Gateway
import { NotificationGateway } from './notification.gateway';
// DTOs
import { SearchNotificationDto } from './dto/search-notification.dto';
// Interfaces
export interface NotificationJobData {
userId: number;
title: string;
message: string;
type: 'EMAIL' | 'LINE' | 'SYSTEM';
entityType?: string; // e.g., 'rfa'
type: 'EMAIL' | 'LINE' | 'SYSTEM'; // ช่องทางหลักที่ต้องการส่ง (Trigger Type)
entityType?: string; // e.g., 'rfa', 'correspondence'
entityId?: number; // e.g., rfa_id
link?: string; // Deep link to frontend
link?: string; // Deep link to frontend page
}
@Injectable()
@@ -39,98 +43,195 @@ export class NotificationService {
) {}
/**
* ส่งการแจ้งเตือน (Trigger Notification)
* ฟังก์ชันนี้จะตรวจสอบ Preference ของผู้ใช้ และ Push ลง Queue
* ส่งการแจ้งเตือน (Centralized Notification Sender)
* 1. บันทึก DB (System Log)
* 2. ส่ง Real-time (WebSocket)
* 3. ส่ง External (Email/Line) ผ่าน Queue ตาม User Preference
*/
async send(data: NotificationJobData) {
async send(data: NotificationJobData): Promise<void> {
try {
// 1. สร้าง Entity Instance (ยังไม่บันทึกลง DB)
// ใช้ Enum NotificationType.SYSTEM เพื่อให้ตรงกับ Type Definition
// ---------------------------------------------------------
// 1. สร้าง Entity และบันทึกลง DB (เพื่อให้มี History ในระบบ)
// ---------------------------------------------------------
const notification = this.notificationRepo.create({
userId: data.userId,
title: data.title,
message: data.message,
notificationType: NotificationType.SYSTEM,
notificationType: NotificationType.SYSTEM, // ใน DB เก็บเป็น SYSTEM เสมอเพื่อแสดงใน App
entityType: data.entityType,
entityId: data.entityId,
isRead: false,
// link: data.link // ถ้า Entity มี field link ให้ใส่ด้วย
});
// 2. บันทึกลง DB (ต้อง await เพื่อให้ได้ ID กลับมา)
const savedNotification = await this.notificationRepo.save(notification);
// 3. Real-time Push (ผ่าน WebSocket Gateway)
// ส่งข้อมูลที่ save แล้ว (มี ID) ไปให้ Frontend
// ---------------------------------------------------------
// 2. Real-time Push (WebSocket) -> ส่งให้ User ทันทีถ้า Online
// ---------------------------------------------------------
this.notificationGateway.sendToUser(data.userId, savedNotification);
// 4. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
// ---------------------------------------------------------
// 3. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
// ---------------------------------------------------------
const userPref = await this.userPrefRepo.findOne({
where: { userId: data.userId },
});
// Default: ถ้าไม่มี Pref ให้ส่ง Email/Line เป็นค่าเริ่มต้น (true)
const shouldSendEmail = userPref ? userPref.notifyEmail : true;
const shouldSendLine = userPref ? userPref.notifyLine : true;
// ใช้ Nullish Coalescing Operator (??)
// ถ้าไม่มีค่า (undefined/null) ให้ Default เป็น true
const shouldSendEmail = userPref?.notifyEmail ?? true;
const shouldSendLine = userPref?.notifyLine ?? true;
const jobs = [];
// 5. Push to Queue (Email)
// เงื่อนไข: User เปิดรับ Email และ Type ของ Noti นี้ไม่ใช่ LINE-only
// ---------------------------------------------------------
// 4. เตรียม Job สำหรับ Email Queue
// เงื่อนไข: User เปิดรับ Email และ Noti นี้ไม่ได้บังคับส่งแค่ LINE
// ---------------------------------------------------------
if (shouldSendEmail && data.type !== 'LINE') {
jobs.push({
name: 'send-email',
data: { ...data, notificationId: savedNotification.id },
data: {
...data,
notificationId: savedNotification.id,
target: 'EMAIL',
},
opts: {
attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม
attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม (Resilience)
backoff: {
type: 'exponential',
delay: 5000, // รอ 5 วิ, 10 วิ, 20 วิ...
delay: 5000, // รอ 5s, 10s, 20s...
},
removeOnComplete: true, // ลบ Job เมื่อเสร็จ (ประหยัด Redis Memory)
},
});
}
// 6. Push to Queue (Line)
// เงื่อนไข: User เปิดรับ Line และ Type ของ Noti นี้ไม่ใช่ EMAIL-only
// ---------------------------------------------------------
// 5. เตรียม Job สำหรับ Line Queue
// เงื่อนไข: User เปิดรับ Line และ Noti นี้ไม่ได้บังคับส่งแค่ EMAIL
// ---------------------------------------------------------
if (shouldSendLine && data.type !== 'EMAIL') {
jobs.push({
name: 'send-line',
data: { ...data, notificationId: savedNotification.id },
data: {
...data,
notificationId: savedNotification.id,
target: 'LINE',
},
opts: {
attempts: 3,
backoff: { type: 'fixed', delay: 3000 },
removeOnComplete: true,
},
});
}
// ---------------------------------------------------------
// 6. Push Jobs ลง Redis BullMQ
// ---------------------------------------------------------
if (jobs.length > 0) {
await this.notificationQueue.addBulk(jobs);
this.logger.debug(
`Queued ${jobs.length} external notifications for user ${data.userId}`,
);
}
this.logger.log(`Notification queued for user ${data.userId}`);
} catch (error) {
// Cast Error เพื่อให้ TypeScript ไม่ฟ้องใน Strict Mode
// Error Handling: ไม่ Throw เพื่อไม่ให้ Flow หลัก (เช่น การสร้างเอกสาร) พัง
// แต่บันทึก Error ไว้ตรวจสอบ
this.logger.error(
`Failed to queue notification: ${(error as Error).message}`,
`Failed to process notification for user ${data.userId}`,
(error as Error).stack,
);
// Note: ไม่ Throw error เพื่อไม่ให้กระทบ Flow หลัก (Resilience Pattern)
}
}
/**
* ดึงรายการแจ้งเตือนของ User (สำหรับ Controller)
*/
async findAll(userId: number, searchDto: SearchNotificationDto) {
const { page = 1, limit = 20, isRead } = searchDto;
const skip = (page - 1) * limit;
const queryBuilder = this.notificationRepo
.createQueryBuilder('notification')
.where('notification.userId = :userId', { userId })
.orderBy('notification.createdAt', 'DESC')
.take(limit)
.skip(skip);
// Filter by Read Status (ถ้ามีการส่งมา)
if (isRead !== undefined) {
queryBuilder.andWhere('notification.isRead = :isRead', { isRead });
}
const [items, total] = await queryBuilder.getManyAndCount();
// นับจำนวนที่ยังไม่ได้อ่านทั้งหมด (เพื่อแสดง Badge ที่กระดิ่ง)
const unreadCount = await this.notificationRepo.count({
where: { userId, isRead: false },
});
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
unreadCount,
},
};
}
/**
* อ่านแจ้งเตือน (Mark as Read)
*/
async markAsRead(id: number, userId: number) {
await this.notificationRepo.update({ id, userId }, { isRead: true });
async markAsRead(id: number, userId: number): Promise<void> {
const notification = await this.notificationRepo.findOne({
where: { id, userId },
});
if (!notification) {
throw new NotFoundException(`Notification #${id} not found`);
}
if (!notification.isRead) {
notification.isRead = true;
await this.notificationRepo.save(notification);
// Update Unread Count via WebSocket (Optional)
// this.notificationGateway.sendUnreadCount(userId, ...);
}
}
/**
* อ่านทั้งหมด (Mark All as Read)
*/
async markAllAsRead(userId: number) {
async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update(
{ userId, isRead: false },
{ isRead: true },
);
}
/**
* ลบการแจ้งเตือนที่เก่าเกินกำหนด (ใช้กับ Cron Job Cleanup)
* เก็บไว้ 90 วัน
*/
async cleanupOldNotifications(days: number = 90): Promise<number> {
const dateLimit = new Date();
dateLimit.setDate(dateLimit.getDate() - days);
const result = await this.notificationRepo
.createQueryBuilder()
.delete()
.from(Notification)
.where('createdAt < :dateLimit', { dateLimit })
.execute();
this.logger.log(`Cleaned up ${result.affected} old notifications`);
return result.affected ?? 0;
}
}

View File

@@ -1,3 +1,4 @@
// File: src/modules/rfa/rfa.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -10,25 +11,27 @@ import { RfaStatusCode } from './entities/rfa-status-code.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { RfaWorkflow } from './entities/rfa-workflow.entity';
import { RfaWorkflowTemplate } from './entities/rfa-workflow-template.entity';
import { RfaWorkflowTemplateStep } from './entities/rfa-workflow-template-step.entity';
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
// หมายเหตุ: ตรวจสอบชื่อไฟล์ Entity ให้ตรงกับที่มีจริง (บางทีอาจชื่อ RoutingTemplate)
// Services
// Services & Controllers
import { RfaService } from './rfa.service';
// Controllers
import { RfaController } from './rfa.controller';
// External Modules
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { UserModule } from '../user/user.module';
import { SearchModule } from '../search/search.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; // ✅ Import
import { NotificationModule } from '../notification/notification.module'; // ✅ เพิ่ม NotificationModule
// ... imports
import { RfaWorkflow } from './entities/rfa-workflow.entity';
import { RfaWorkflowTemplate } from './entities/rfa-workflow-template.entity';
import { RfaWorkflowTemplateStep } from './entities/rfa-workflow-template-step.entity';
import { SearchModule } from '../search/search.module'; // ✅ เพิ่ม
@Module({
imports: [
// 1. Register Entities (เฉพาะ Entity เท่านั้น ห้ามใส่ Module)
TypeOrmModule.forFeature([
Rfa,
RfaRevision,
@@ -38,14 +41,19 @@ import { SearchModule } from '../search/search.module'; // ✅ เพิ่ม
RfaApproveCode,
Correspondence,
ShopDrawingRevision,
// ... (ตัวเดิม)
RfaWorkflow,
RfaWorkflowTemplate,
RfaWorkflowTemplateStep,
CorrespondenceRouting,
RoutingTemplate,
]),
// 2. Import External Modules (Services ที่ Inject เข้ามา)
DocumentNumberingModule,
UserModule,
SearchModule,
WorkflowEngineModule, // ✅ ย้ายมาใส่ตรงนี้ (imports หลัก)
NotificationModule, // ✅ เพิ่มตรงนี้ เพื่อแก้ dependency index [13]
],
providers: [RfaService],
controllers: [RfaController],

View File

@@ -7,11 +7,13 @@ import { TransmittalService } from './transmittal.service';
import { TransmittalController } from './transmittal.controller';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { UserModule } from '../user/user.module';
import { SearchModule } from '../search/search.module'; // ✅ ต้อง Import เพราะ Service ใช้ (ที่เป็นสาเหตุ Error)
@Module({
imports: [
TypeOrmModule.forFeature([Transmittal, TransmittalItem, Correspondence]),
DocumentNumberingModule,
UserModule,
SearchModule,
],
controllers: [TransmittalController],
providers: [TransmittalService],

View File

@@ -1,21 +1,7 @@
// File: src/modules/user/dto/update-preference.dto.ts
import { IsBoolean, IsOptional, IsString, IsIn } from 'class-validator';
// File: src/modules/user/dto/update-user.dto.ts
// บันทึกการแก้ไข: ใช้ PartialType จาก @nestjs/swagger เพื่อรองรับ API Docs (T1.3)
export class UpdatePreferenceDto {
@IsOptional()
@IsBoolean()
notifyEmail?: boolean;
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
@IsOptional()
@IsBoolean()
notifyLine?: boolean;
@IsOptional()
@IsBoolean()
digestMode?: boolean;
@IsOptional()
@IsString()
@IsIn(['light', 'dark', 'system'])
uiTheme?: string;
}
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@@ -22,7 +22,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
import { AssignRoleDto } from './dto/assign-role.dto';
import { UpdatePreferenceDto } from './dto/update-preference.dto'; // ✅ เพิ่ม DTO
import { JwtAuthGuard } from '../../common/auth/guards/jwt-auth.guard';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; // สมมติว่ามีแล้ว ถ้ายังไม่มีให้คอมเมนต์ไว้ก่อน
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';

View File

@@ -1,10 +1,16 @@
// File: src/modules/user/user.service.ts
// บันทึกการแก้ไข: แก้ไข Error TS1272 โดยใช้ 'import type' สำหรับ Cache interface (T1.3)
import {
Injectable,
NotFoundException,
ConflictException,
Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager'; // ✅ FIX: เพิ่ม 'type' ตรงนี้
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
@@ -14,26 +20,23 @@ import { UpdateUserDto } from './dto/update-user.dto';
export class UserService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>, // ✅ ชื่อตัวแปรจริงคือ usersRepository
private usersRepository: Repository<User>,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
// 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
async create(createUserDto: CreateUserDto): Promise<User> {
// สร้าง Salt และ Hash Password
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(createUserDto.password, salt);
// เตรียมข้อมูล (เปลี่ยน password ธรรมดา เป็น password_hash)
const newUser = this.usersRepository.create({
...createUserDto,
password: hashedPassword,
});
try {
// บันทึกลง DB
return await this.usersRepository.save(newUser);
} catch (error: any) {
// เช็ค Error กรณี Username/Email ซ้ำ (MySQL Error Code 1062)
if (error.code === 'ER_DUP_ENTRY') {
throw new ConflictException('Username or Email already exists');
}
@@ -44,7 +47,6 @@ export class UserService {
// 2. ดึงข้อมูลทั้งหมด
async findAll(): Promise<User[]> {
return this.usersRepository.find({
// ไม่ส่ง password กลับไปเพื่อความปลอดภัย
select: [
'user_id',
'username',
@@ -61,7 +63,7 @@ export class UserService {
// 3. ดึงข้อมูลรายคน
async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({
where: { user_id: id }, // ใช้ user_id ตาม Entity
where: { user_id: id },
});
if (!user) {
@@ -71,26 +73,26 @@ export class UserService {
return user;
}
// ฟังก์ชันแถม: สำหรับ AuthService ใช้ (ต้องเห็น Password เพื่อเอาไปเทียบ)
async findOneByUsername(username: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { username } });
}
// 4. แก้ไขข้อมูล
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
// เช็คก่อนว่ามี User นี้ไหม
const user = await this.findOne(id);
// ถ้ามีการแก้รหัสผ่าน ต้อง Hash ใหม่ด้วย
if (updateUserDto.password) {
const salt = await bcrypt.genSalt();
updateUserDto.password = await bcrypt.hash(updateUserDto.password, salt);
}
// รวมร่างข้อมูลเดิม + ข้อมูลใหม่
const updatedUser = this.usersRepository.merge(user, updateUserDto);
const savedUser = await this.usersRepository.save(updatedUser);
return this.usersRepository.save(updatedUser);
// ⚠️ สำคัญ: เมื่อมีการแก้ไขข้อมูล User ต้องเคลียร์ Cache สิทธิ์เสมอ
await this.clearUserCache(id);
return savedUser;
}
// 5. ลบผู้ใช้ (Soft Delete)
@@ -100,31 +102,48 @@ export class UserService {
if (result.affected === 0) {
throw new NotFoundException(`User with ID ${id} not found`);
}
// เคลียร์ Cache เมื่อลบ
await this.clearUserCache(id);
}
/**
* หา User ID ของคนที่เป็น Document Control (หรือตัวแทน) ในองค์กร
* เพื่อส่ง Notification
*/
async findDocControlIdByOrg(organizationId: number): Promise<number | null> {
// ✅ FIX: ใช้ usersRepository ให้ตรงกับ Constructor
const user = await this.usersRepository.findOne({
where: { primaryOrganizationId: organizationId },
// order: { roleId: 'ASC' } // (Optional) Logic การเลือกคน
});
return user ? user.user_id : null;
}
// ฟังก์ชันดึงสิทธิ์ (Permission)
/**
* ✅ ดึงสิทธิ์ (Permission) โดยใช้ Caching Strategy
* TTL: 30 นาที (ตาม Requirement 6.5.2)
*/
async getUserPermissions(userId: number): Promise<string[]> {
// Query ข้อมูลจาก View: v_user_all_permissions
const cacheKey = `permissions:user:${userId}`;
// 1. ลองดึงจาก Cache ก่อน
const cachedPermissions = await this.cacheManager.get<string[]>(cacheKey);
if (cachedPermissions) {
return cachedPermissions;
}
// 2. ถ้าไม่มีใน Cache ให้ Query จาก DB (View: v_user_all_permissions)
const permissions = await this.usersRepository.query(
`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`,
[userId],
);
// แปลงผลลัพธ์เป็น Array ของ string ['user.create', 'project.view', ...]
return permissions.map((row: any) => row.permission_name);
const permissionList = permissions.map((row: any) => row.permission_name);
// 3. บันทึกลง Cache (TTL 1800 วินาที = 30 นาที)
await this.cacheManager.set(cacheKey, permissionList, 1800 * 1000);
return permissionList;
}
/**
* Helper สำหรับล้าง Cache เมื่อมีการเปลี่ยนแปลงสิทธิ์หรือบทบาท
*/
async clearUserCache(userId: number): Promise<void> {
await this.cacheManager.del(`permissions:user:${userId}`);
}
}

View File

@@ -0,0 +1,26 @@
// File: src/modules/workflow-engine/dto/create-workflow-definition.dto.ts
import {
IsString,
IsNotEmpty,
IsObject,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateWorkflowDefinitionDto {
@ApiProperty({ example: 'RFA', description: 'รหัสของ Workflow' })
@IsString()
@IsNotEmpty()
workflow_code!: string; // เพิ่ม !
@ApiProperty({ description: 'นิยาม Workflow' })
@IsObject()
@IsNotEmpty()
dsl!: any; // เพิ่ม !
@ApiProperty({ description: 'เปิดใช้งานทันทีหรือไม่', default: true })
@IsBoolean()
@IsOptional()
is_active?: boolean;
}

View File

@@ -0,0 +1,25 @@
// File: src/modules/workflow-engine/dto/evaluate-workflow.dto.ts
import { IsString, IsNotEmpty, IsObject, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class EvaluateWorkflowDto {
@ApiProperty({ example: 'RFA', description: 'รหัส Workflow' })
@IsString()
@IsNotEmpty()
workflow_code!: string; // เพิ่ม !
@ApiProperty({ example: 'DRAFT', description: 'สถานะปัจจุบัน' })
@IsString()
@IsNotEmpty()
current_state!: string; // เพิ่ม !
@ApiProperty({ example: 'SUBMIT', description: 'Action ที่ต้องการทำ' })
@IsString()
@IsNotEmpty()
action!: string; // เพิ่ม !
@ApiProperty({ description: 'Context', example: { userId: 1 } })
@IsObject()
@IsOptional()
context?: Record<string, any>;
}

View File

@@ -0,0 +1,15 @@
// File: src/modules/workflow-engine/dto/get-available-actions.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GetAvailableActionsDto {
@ApiProperty({ description: 'รหัส Workflow', example: 'RFA' })
@IsString()
@IsNotEmpty()
workflow_code!: string; // เพิ่ม !
@ApiProperty({ description: 'สถานะปัจจุบัน', example: 'DRAFT' })
@IsString()
@IsNotEmpty()
current_state!: string; // เพิ่ม !
}

View File

@@ -0,0 +1,10 @@
// File: src/modules/workflow-engine/dto/update-workflow-definition.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateWorkflowDefinitionDto } from './create-workflow-definition.dto';
// PartialType จะทำให้ทุก field ใน CreateDto กลายเป็น Optional (?)
// เหมาะสำหรับ PATCH method
export class UpdateWorkflowDefinitionDto extends PartialType(
CreateWorkflowDefinitionDto,
) {}

View File

@@ -0,0 +1,37 @@
// File: src/modules/workflow-engine/entities/workflow-definition.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('workflow_definitions')
@Index(['workflow_code', 'is_active', 'version'])
export class WorkflowDefinition {
@PrimaryGeneratedColumn('uuid')
id!: string; // เพิ่ม !
@Column({ length: 50, comment: 'รหัส Workflow เช่น RFA, CORR' })
workflow_code!: string; // เพิ่ม !
@Column({ type: 'int', default: 1, comment: 'หมายเลข Version' })
version!: number; // เพิ่ม !
@Column({ type: 'json', comment: 'นิยาม Workflow ต้นฉบับ' })
dsl!: any; // เพิ่ม !
@Column({ type: 'json', comment: 'โครงสร้างที่ Compile แล้ว' })
compiled!: any; // เพิ่ม !
@Column({ default: true, comment: 'สถานะการใช้งาน' })
is_active!: boolean; // เพิ่ม !
@CreateDateColumn()
created_at!: Date; // เพิ่ม !
@UpdateDateColumn()
updated_at!: Date; // เพิ่ม !
}

View File

@@ -0,0 +1,203 @@
// File: src/modules/workflow-engine/workflow-dsl.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
export interface WorkflowState {
initial?: boolean;
terminal?: boolean;
transitions?: Record<string, TransitionRule>;
}
export interface TransitionRule {
to: string;
requirements?: RequirementRule[];
events?: EventRule[];
}
export interface RequirementRule {
role?: string;
user?: string;
condition?: string; // e.g. "amount > 5000" (Advanced)
}
export interface EventRule {
type: 'notify' | 'webhook' | 'update_status';
target?: string;
payload?: any;
}
export interface CompiledWorkflow {
workflow: string;
version: string | number;
states: Record<string, WorkflowState>;
}
@Injectable()
export class WorkflowDslService {
/**
* คอมไพล์ DSL Input ให้เป็น Standard Execution Tree
* @param dsl ข้อมูลดิบจาก User (JSON/Object)
* @returns CompiledWorkflow Object ที่พร้อมใช้งาน
*/
compile(dsl: any): CompiledWorkflow {
// 1. Basic Structure Validation
if (!dsl.states || !Array.isArray(dsl.states)) {
throw new BadRequestException(
'DSL syntax error: "states" array is required.',
);
}
const compiled: CompiledWorkflow = {
workflow: dsl.workflow || 'UNKNOWN',
version: dsl.version || 1,
states: {},
};
const stateMap = new Set<string>();
// 2. First Pass: Collect all state names and normalize structure
for (const rawState of dsl.states) {
if (!rawState.name) {
throw new BadRequestException(
'DSL syntax error: All states must have a "name".',
);
}
stateMap.add(rawState.name);
const normalizedState: WorkflowState = {
initial: !!rawState.initial,
terminal: !!rawState.terminal,
transitions: {},
};
// Normalize transitions "on:"
if (rawState.on) {
for (const [action, rule] of Object.entries(rawState.on)) {
const rawRule = rule as any;
normalizedState.transitions![action] = {
to: rawRule.to,
requirements: rawRule.require || [],
events: rawRule.events || [],
};
}
}
compiled.states[rawState.name] = normalizedState;
}
// 3. Second Pass: Validate Integrity
this.validateIntegrity(compiled, stateMap);
return compiled;
}
/**
* ตรวจสอบความสมบูรณ์ของ Workflow Logic
*/
private validateIntegrity(compiled: CompiledWorkflow, stateMap: Set<string>) {
let hasInitial = false;
for (const [stateName, state] of Object.entries(compiled.states)) {
if (state.initial) {
if (hasInitial)
throw new BadRequestException(
`DSL Error: Multiple initial states found.`,
);
hasInitial = true;
}
// ตรวจสอบ Transitions
if (state.transitions) {
for (const [action, rule] of Object.entries(state.transitions)) {
// 1. ปลายทางต้องมีอยู่จริง
if (!stateMap.has(rule.to)) {
throw new BadRequestException(
`DSL Error: State "${stateName}" transitions via "${action}" to unknown state "${rule.to}".`,
);
}
// 2. Action name convention (Optional but recommended)
if (!/^[A-Z0-9_]+$/.test(action)) {
// Warning or Strict Error could be here
}
}
}
}
if (!hasInitial) {
throw new BadRequestException('DSL Error: No initial state defined.');
}
}
/**
* ประเมินผล (Evaluate) การเปลี่ยนสถานะ
* @param compiled ข้อมูล Workflow ที่ Compile แล้ว
* @param currentState สถานะปัจจุบัน
* @param action การกระทำ
* @param context ข้อมูลประกอบ (User roles, etc.)
*/
evaluate(
compiled: CompiledWorkflow,
currentState: string,
action: string,
context: any,
): { nextState: string; events: EventRule[] } {
const stateConfig = compiled.states[currentState];
if (!stateConfig) {
throw new BadRequestException(
`Runtime Error: Current state "${currentState}" not found in definition.`,
);
}
if (stateConfig.terminal) {
throw new BadRequestException(
`Runtime Error: Cannot transition from terminal state "${currentState}".`,
);
}
const transition = stateConfig.transitions?.[action];
if (!transition) {
throw new BadRequestException(
`Runtime Error: Action "${action}" is not allowed from state "${currentState}". Available actions: ${Object.keys(stateConfig.transitions || {}).join(', ')}`,
);
}
// Check Requirements (RBAC Logic inside Engine)
if (transition.requirements && transition.requirements.length > 0) {
this.checkRequirements(transition.requirements, context);
}
return {
nextState: transition.to,
events: transition.events || [],
};
}
/**
* ตรวจสอบเงื่อนไขสิทธิ์ (Requirements)
*/
private checkRequirements(requirements: RequirementRule[], context: any) {
const userRoles = context.roles || [];
const userId = context.userId;
const isAllowed = requirements.some((req) => {
// กรณีเช็ค Role
if (req.role) {
return userRoles.includes(req.role);
}
// กรณีเช็ค Specific User
if (req.user) {
return userId === req.user;
}
return false;
});
if (!isAllowed) {
throw new BadRequestException(
'Access Denied: You do not meet the requirements for this action.',
);
}
}
}

View File

@@ -0,0 +1,65 @@
// File: src/modules/workflow-engine/workflow-engine.controller.ts
import {
Controller,
Post,
Body,
Get,
Query,
Patch,
Param,
UseGuards,
} from '@nestjs/common'; // เพิ่ม Patch, Param
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { WorkflowEngineService } from './workflow-engine.service';
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { GetAvailableActionsDto } from './dto/get-available-actions.dto'; // [NEW]
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto'; // [NEW]
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@ApiTags('Workflow Engine (DSL)')
@Controller('workflow-engine')
@UseGuards(JwtAuthGuard) // Protect all endpoints
export class WorkflowEngineController {
constructor(private readonly workflowService: WorkflowEngineService) {}
@Post('definitions')
@ApiOperation({ summary: 'Create or Update Workflow Definition (DSL)' })
@ApiResponse({ status: 201, description: 'Workflow compiled and saved.' })
async createDefinition(@Body() dto: CreateWorkflowDefinitionDto) {
return this.workflowService.createDefinition(dto);
}
@Post('evaluate')
@ApiOperation({
summary: 'Evaluate transition (Run logic without saving state)',
})
async evaluate(@Body() dto: EvaluateWorkflowDto) {
return this.workflowService.evaluate(dto);
}
@Get('actions')
@ApiOperation({ summary: 'Get available actions for current state' })
async getAvailableActions(@Query() query: GetAvailableActionsDto) {
// [UPDATED] ใช้ DTO แทนแยก Query
return this.workflowService.getAvailableActions(
query.workflow_code,
query.current_state,
);
}
// [OPTIONAL/RECOMMENDED] เพิ่ม Endpoint สำหรับ Update (PATCH)
@Patch('definitions/:id')
@ApiOperation({
summary: 'Update workflow status or details (e.g. Deactivate)',
})
async updateDefinition(
@Param('id') id: string,
@Body() dto: UpdateWorkflowDefinitionDto, // [NEW] ใช้ Update DTO
) {
// *หมายเหตุ: คุณต้องไปเพิ่ม method update() ใน Service ด้วยถ้าจะใช้ Endpoint นี้
// return this.workflowService.update(id, dto);
return { message: 'Update logic not implemented yet', id, ...dto };
}
}

View File

@@ -1,9 +1,22 @@
// File: src/modules/workflow-engine/workflow-engine.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkflowEngineService } from './workflow-engine.service';
import { WorkflowDslService } from './workflow-dsl.service'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
import { WorkflowEngineController } from './workflow-engine.controller'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
import { WorkflowDefinition } from './entities/workflow-definition.entity'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
@Module({
providers: [WorkflowEngineService],
// ✅ เพิ่มบรรทัดนี้ เพื่ออนุญาตให้ Module อื่น (เช่น Correspondence) เรียกใช้ Service นี้ได้
exports: [WorkflowEngineService],
imports: [
// เชื่อมต่อกับตาราง workflow_definitions
TypeOrmModule.forFeature([WorkflowDefinition]),
],
controllers: [WorkflowEngineController], // เพิ่ม Controller สำหรับรับ API
providers: [
WorkflowEngineService, // Service หลัก
WorkflowDslService, // [New] Service สำหรับ Compile/Validate DSL
],
exports: [WorkflowEngineService], // Export ให้ module อื่นใช้เหมือนเดิม
})
export class WorkflowEngineModule {}

View File

@@ -1,45 +1,179 @@
import { Injectable, BadRequestException } from '@nestjs/common';
// File: src/modules/workflow-engine/workflow-engine.service.ts
import {
WorkflowStep,
WorkflowAction,
StepStatus,
TransitionResult,
} from './interfaces/workflow.interface.js';
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkflowDefinition } from './entities/workflow-definition.entity';
import { WorkflowDslService, CompiledWorkflow } from './workflow-dsl.service';
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
// Interface สำหรับ Backward Compatibility (Logic เดิม)
export enum WorkflowAction {
APPROVE = 'APPROVE',
REJECT = 'REJECT',
RETURN = 'RETURN',
ACKNOWLEDGE = 'ACKNOWLEDGE',
}
export interface TransitionResult {
nextStepSequence: number | null;
shouldUpdateStatus: boolean;
documentStatus?: string;
}
@Injectable()
export class WorkflowEngineService {
private readonly logger = new Logger(WorkflowEngineService.name);
constructor(
@InjectRepository(WorkflowDefinition)
private readonly workflowDefRepo: Repository<WorkflowDefinition>,
private readonly dslService: WorkflowDslService,
) {}
// =================================================================
// [NEW] DSL & Workflow Engine (Phase 6A)
// =================================================================
/**
* คำนวณสถานะถัดไป (Next State Transition)
* @param currentSequence ลำดับปัจจุบัน
* @param totalSteps จำนวนขั้นตอนทั้งหมด
* @param action การกระทำ (Approve/Reject/Return)
* @param returnToSequence (Optional) ถ้า Return จะให้กลับไปขั้นไหน
* สร้างหรืออัปเดต Workflow Definition ใหม่ (Auto Versioning)
*/
async createDefinition(
dto: CreateWorkflowDefinitionDto,
): Promise<WorkflowDefinition> {
const compiled = this.dslService.compile(dto.dsl);
const latest = await this.workflowDefRepo.findOne({
where: { workflow_code: dto.workflow_code },
order: { version: 'DESC' },
});
const nextVersion = latest ? latest.version + 1 : 1;
const entity = this.workflowDefRepo.create({
workflow_code: dto.workflow_code,
version: nextVersion,
dsl: dto.dsl,
compiled: compiled,
is_active: dto.is_active ?? true,
});
return this.workflowDefRepo.save(entity);
}
async update(
id: string,
dto: UpdateWorkflowDefinitionDto,
): Promise<WorkflowDefinition> {
const definition = await this.workflowDefRepo.findOne({ where: { id } });
if (!definition) {
throw new NotFoundException(
`Workflow Definition with ID "${id}" not found`,
);
}
if (dto.dsl) {
try {
const compiled = this.dslService.compile(dto.dsl);
definition.dsl = dto.dsl;
definition.compiled = compiled;
} catch (error: any) {
throw new BadRequestException(`Invalid DSL: ${error.message}`);
}
}
if (dto.is_active !== undefined) definition.is_active = dto.is_active;
if (dto.workflow_code) definition.workflow_code = dto.workflow_code;
return this.workflowDefRepo.save(definition);
}
async evaluate(dto: EvaluateWorkflowDto): Promise<any> {
const definition = await this.workflowDefRepo.findOne({
where: { workflow_code: dto.workflow_code, is_active: true },
order: { version: 'DESC' },
});
if (!definition) {
throw new NotFoundException(
`No active workflow definition found for "${dto.workflow_code}"`,
);
}
const compiled: CompiledWorkflow = definition.compiled;
const result = this.dslService.evaluate(
compiled,
dto.current_state,
dto.action,
dto.context || {},
);
this.logger.log(
`Workflow Evaluated: ${dto.workflow_code} [${dto.current_state}] --${dto.action}--> [${result.nextState}]`,
);
return result;
}
async getAvailableActions(
workflowCode: string,
currentState: string,
): Promise<string[]> {
const definition = await this.workflowDefRepo.findOne({
where: { workflow_code: workflowCode, is_active: true },
order: { version: 'DESC' },
});
if (!definition) return [];
const stateConfig = definition.compiled.states[currentState];
if (!stateConfig || !stateConfig.transitions) return [];
return Object.keys(stateConfig.transitions);
}
// =================================================================
// [LEGACY] Backward Compatibility for Correspondence/RFA Modules
// คืนค่า Logic เดิมเพื่อไม่ให้ Module อื่น Error (TS2339)
// =================================================================
/**
* คำนวณสถานะถัดไปแบบ Linear Sequence (Logic เดิม)
* ใช้สำหรับ CorrespondenceService และ RfaService ที่ยังไม่ได้ Refactor
*/
processAction(
currentSequence: number,
totalSteps: number,
action: WorkflowAction,
action: string, // รับเป็น string เพื่อความยืดหยุ่น
returnToSequence?: number,
): TransitionResult {
// Map string action to enum logic
switch (action) {
case WorkflowAction.APPROVE:
case WorkflowAction.ACKNOWLEDGE:
// ถ้าเป็นขั้นตอนสุดท้าย -> จบ Workflow
case 'APPROVE': // Case sensitive handling fallback
case 'ACKNOWLEDGE':
if (currentSequence >= totalSteps) {
return {
nextStepSequence: null, // ไม่มีขั้นต่อไปแล้ว
nextStepSequence: null,
shouldUpdateStatus: true,
documentStatus: 'COMPLETED', // หรือ APPROVED
documentStatus: 'COMPLETED',
};
}
// ถ้ายังไม่จบ -> ไปขั้นต่อไป
return {
nextStepSequence: currentSequence + 1,
shouldUpdateStatus: false,
};
case WorkflowAction.REJECT:
// จบ Workflow ทันทีแบบไม่สวย
case 'REJECT':
return {
nextStepSequence: null,
shouldUpdateStatus: true,
@@ -47,7 +181,7 @@ export class WorkflowEngineService {
};
case WorkflowAction.RETURN:
// ย้อนกลับไปขั้นตอนก่อนหน้า (หรือที่ระบุ)
case 'RETURN':
const targetStep = returnToSequence || currentSequence - 1;
if (targetStep < 1) {
throw new BadRequestException('Cannot return beyond the first step');
@@ -55,38 +189,25 @@ export class WorkflowEngineService {
return {
nextStepSequence: targetStep,
shouldUpdateStatus: true,
documentStatus: 'REVISE_REQUIRED', // สถานะเอกสารเป็น "รอแก้ไข"
documentStatus: 'REVISE_REQUIRED',
};
default:
throw new BadRequestException(`Invalid action: ${action}`);
// กรณีส่ง Action อื่นมา ให้ถือว่าเป็น Approve (หรือจะ Throw Error ก็ได้)
this.logger.warn(
`Unknown legacy action: ${action}, treating as next step.`,
);
if (currentSequence >= totalSteps) {
return {
nextStepSequence: null,
shouldUpdateStatus: true,
documentStatus: 'COMPLETED',
};
}
return {
nextStepSequence: currentSequence + 1,
shouldUpdateStatus: false,
};
}
}
/**
* ตรวจสอบว่า User คนนี้ มีสิทธิ์กด Action ในขั้นตอนนี้ไหม
* (Logic เบื้องต้น - เดี๋ยวเราจะเชื่อมกับ RBAC จริงๆ ใน Service หลัก)
*/
validateAccess(
step: WorkflowStep,
userOrgId: number,
userId: number,
): boolean {
// ถ้าขั้นตอนนี้ยังไม่ Active (เช่น PENDING หรือ SKIPPED) -> ห้ามยุ่ง
if (step.status !== StepStatus.IN_PROGRESS) {
return false;
}
// เช็คว่าตรงกับ Organization ที่กำหนดไหม
if (step.organizationId && step.organizationId !== userOrgId) {
return false;
}
// เช็คว่าตรงกับ User ที่กำหนดไหม (ถ้าระบุ)
if (step.assigneeId && step.assigneeId !== userId) {
return false;
}
return true;
}
}