251129:1700 update to 1.4.5
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
// File: src/modules/workflow-engine/dto/workflow-transition.dto.ts
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class WorkflowTransitionDto {
|
||||
@ApiProperty({
|
||||
description: 'ชื่อ Action ที่ต้องการทำ (ต้องตรงกับที่กำหนดใน DSL)',
|
||||
example: 'APPROVE',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
action!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'ความเห็นประกอบการดำเนินการ',
|
||||
example: 'อนุมัติครับ ดำเนินการต่อได้เลย',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'ข้อมูลเพิ่มเติมที่ต้องการแนบไปกับ Event หรือบันทึกใน Context',
|
||||
example: { urgent: true, assign_to: 'user_123' },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
payload?: Record<string, any>;
|
||||
}
|
||||
@@ -1,37 +1,58 @@
|
||||
// File: src/modules/workflow-engine/entities/workflow-definition.entity.ts
|
||||
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* เก็บแม่แบบ (Blueprint) ของ Workflow
|
||||
* 1 Workflow Code (เช่น RFA) สามารถมีได้หลาย Version
|
||||
*/
|
||||
@Entity('workflow_definitions')
|
||||
@Index(['workflow_code', 'is_active', 'version'])
|
||||
@Unique(['workflow_code', 'version']) // ป้องกัน Version ซ้ำใน Workflow เดียวกัน
|
||||
@Index(['workflow_code', 'is_active', 'version']) // เพื่อการ Query หา Active Version ล่าสุดได้เร็ว
|
||||
export class WorkflowDefinition {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string; // เพิ่ม !
|
||||
id!: string;
|
||||
|
||||
@Column({ length: 50, comment: 'รหัส Workflow เช่น RFA, CORR' })
|
||||
workflow_code!: string; // เพิ่ม !
|
||||
@Column({ length: 50, comment: 'รหัส Workflow เช่น RFA, CORR, LEAVE_REQ' })
|
||||
workflow_code!: string;
|
||||
|
||||
@Column({ type: 'int', default: 1, comment: 'หมายเลข Version' })
|
||||
version!: number; // เพิ่ม !
|
||||
@Column({
|
||||
type: 'int',
|
||||
default: 1,
|
||||
comment: 'หมายเลข Version (Running sequence)',
|
||||
})
|
||||
version!: number;
|
||||
|
||||
@Column({ type: 'json', comment: 'นิยาม Workflow ต้นฉบับ' })
|
||||
dsl!: any; // เพิ่ม !
|
||||
@Column({ type: 'text', nullable: true, comment: 'คำอธิบายเพิ่มเติม' })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'json', comment: 'โครงสร้างที่ Compile แล้ว' })
|
||||
compiled!: any; // เพิ่ม !
|
||||
@Column({
|
||||
type: 'json',
|
||||
comment: 'Raw DSL ที่ User/Admin เขียน (เก็บไว้เพื่อดูหรือแก้ไข)',
|
||||
})
|
||||
dsl!: any; // ควรตรงกับ RawWorkflowDSL interface
|
||||
|
||||
@Column({ default: true, comment: 'สถานะการใช้งาน' })
|
||||
is_active!: boolean; // เพิ่ม !
|
||||
@Column({
|
||||
type: 'json',
|
||||
comment:
|
||||
'Compiled JSON Structure ที่ผ่านการ Validate และ Optimize สำหรับ Runtime Engine แล้ว',
|
||||
})
|
||||
compiled!: any; // ควรตรงกับ CompiledWorkflow interface
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at!: Date; // เพิ่ม !
|
||||
@Column({ default: true, comment: 'สถานะการใช้งาน (Soft Disable)' })
|
||||
is_active!: boolean;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at!: Date; // เพิ่ม !
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
created_at!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updated_at!: Date;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
// File: src/modules/workflow-engine/entities/workflow-history.entity.ts
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { WorkflowInstance } from './workflow-instance.entity';
|
||||
|
||||
/**
|
||||
* เก็บประวัติการเปลี่ยนสถานะ (Audit Trail)
|
||||
* สำคัญมากสำหรับการตรวจสอบย้อนหลัง (Who did What, When)
|
||||
*/
|
||||
@Entity('workflow_histories')
|
||||
@Index(['instanceId']) // ค้นหาประวัติของ Instance นี้
|
||||
@Index(['actionByUserId']) // ค้นหาว่า User คนนี้ทำอะไรไปบ้าง
|
||||
export class WorkflowHistory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -21,23 +29,32 @@ export class WorkflowHistory {
|
||||
@Column({ name: 'instance_id' })
|
||||
instanceId!: string;
|
||||
|
||||
@Column({ name: 'from_state', length: 50 })
|
||||
@Column({ name: 'from_state', length: 50, comment: 'สถานะต้นทาง' })
|
||||
fromState!: string;
|
||||
|
||||
@Column({ name: 'to_state', length: 50 })
|
||||
@Column({ name: 'to_state', length: 50, comment: 'สถานะปลายทาง' })
|
||||
toState!: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
@Column({ length: 50, comment: 'Action ที่ User กด (เช่น APPROVE, REJECT)' })
|
||||
action!: string;
|
||||
|
||||
@Column({ name: 'action_by_user_id', nullable: true })
|
||||
actionByUserId?: number; // User ID ของผู้ดำเนินการ
|
||||
@Column({
|
||||
name: 'action_by_user_id',
|
||||
nullable: true,
|
||||
comment: 'User ID ผู้ดำเนินการ (Nullable กรณี System Auto)',
|
||||
})
|
||||
actionByUserId?: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
@Column({ type: 'text', nullable: true, comment: 'ความเห็นประกอบการอนุมัติ' })
|
||||
comment?: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
metadata?: Record<string, any>; // เก็บข้อมูลเพิ่มเติม เช่น Snapshot ของ Context ณ ตอนนั้น
|
||||
// Snapshot ข้อมูล ณ เวลาที่เปลี่ยนสถานะ เพื่อเป็นหลักฐานหาก Context เปลี่ยนในอนาคต
|
||||
@Column({
|
||||
type: 'json',
|
||||
nullable: true,
|
||||
comment: 'Snapshot of Context or Metadata',
|
||||
})
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// File: src/modules/workflow-engine/entities/workflow-instance.entity.ts
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -12,20 +13,23 @@ import {
|
||||
import { WorkflowDefinition } from './workflow-definition.entity';
|
||||
|
||||
export enum WorkflowStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
TERMINATED = 'TERMINATED',
|
||||
ACTIVE = 'ACTIVE', // กำลังดำเนินการ
|
||||
COMPLETED = 'COMPLETED', // จบกระบวนการ (ถึง Terminal State)
|
||||
CANCELLED = 'CANCELLED', // ถูกยกเลิกกลางคัน
|
||||
TERMINATED = 'TERMINATED', // ถูกบังคับจบโดยระบบ หรือ Error
|
||||
}
|
||||
|
||||
/**
|
||||
* เก็บสถานะการเดินเรื่องของเอกสารแต่ละใบ (Runtime State)
|
||||
*/
|
||||
@Entity('workflow_instances')
|
||||
@Index(['entityType', 'entityId']) // Index สำหรับค้นหาตามเอกสาร
|
||||
@Index(['currentState']) // Index สำหรับ Filter ตามสถานะ
|
||||
@Index(['entityType', 'entityId']) // เพื่อค้นหาว่าเอกสารนี้ (เช่น RFA-001) อยู่ขั้นตอนไหน
|
||||
@Index(['currentState']) // เพื่อ Dashboard: "มีงานค้างที่ขั้นตอนไหนบ้าง"
|
||||
export class WorkflowInstance {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// เชื่อมโยงกับ Definition ที่ใช้ตอนสร้าง Instance นี้
|
||||
// ผูกกับ Definition เพื่อรู้ว่าใช้กฎชุดไหน (Version ไหน)
|
||||
@ManyToOne(() => WorkflowDefinition)
|
||||
@JoinColumn({ name: 'definition_id' })
|
||||
definition!: WorkflowDefinition;
|
||||
@@ -33,25 +37,39 @@ export class WorkflowInstance {
|
||||
@Column({ name: 'definition_id' })
|
||||
definitionId!: string;
|
||||
|
||||
// Polymorphic Relation: เชื่อมกับเอกสารได้หลายประเภท (RFA, CORR, etc.)
|
||||
@Column({ name: 'entity_type', length: 50 })
|
||||
// Polymorphic Relation: เชื่อมกับเอกสารได้หลายประเภท (RFA, CORR, etc.) โดยไม่ต้อง Foreign Key จริง
|
||||
@Column({
|
||||
name: 'entity_type',
|
||||
length: 50,
|
||||
comment: 'ประเภทเอกสาร เช่น rfa, correspondence',
|
||||
})
|
||||
entityType!: string;
|
||||
|
||||
@Column({ name: 'entity_id', length: 50 })
|
||||
entityId!: string; // รองรับทั้ง ID แบบ Int และ UUID (เก็บเป็น String)
|
||||
@Column({
|
||||
name: 'entity_id',
|
||||
length: 50,
|
||||
comment: 'ID ของเอกสาร (String/UUID)',
|
||||
})
|
||||
entityId!: string;
|
||||
|
||||
@Column({ name: 'current_state', length: 50 })
|
||||
@Column({
|
||||
name: 'current_state',
|
||||
length: 50,
|
||||
comment: 'ชื่อ State ปัจจุบัน เช่น DRAFT, IN_REVIEW',
|
||||
})
|
||||
currentState!: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: WorkflowStatus,
|
||||
default: WorkflowStatus.ACTIVE,
|
||||
comment: 'สถานะภาพรวมของ Instance',
|
||||
})
|
||||
status!: WorkflowStatus;
|
||||
|
||||
// Context เฉพาะของ Instance นี้ (เช่น ตัวแปรที่ส่งต่อระหว่าง State)
|
||||
@Column({ type: 'json', nullable: true })
|
||||
// Context: เก็บตัวแปรที่จำเป็นสำหรับการตัดสินใจใน Workflow
|
||||
// เช่น { "amount": 500000, "requester_role": "ENGINEER", "approver_ids": [1, 2] }
|
||||
@Column({ type: 'json', nullable: true, comment: 'Runtime Context Data' })
|
||||
context?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
|
||||
@@ -1,181 +1,255 @@
|
||||
// File: src/modules/workflow-engine/workflow-dsl.service.ts
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface WorkflowState {
|
||||
// ==========================================
|
||||
// 1. Interfaces for RAW DSL (Input from User)
|
||||
// ==========================================
|
||||
export interface RawWorkflowDSL {
|
||||
workflow: string;
|
||||
version?: number;
|
||||
description?: string;
|
||||
states: RawState[];
|
||||
}
|
||||
|
||||
export interface RawState {
|
||||
name: string;
|
||||
initial?: boolean;
|
||||
terminal?: boolean;
|
||||
transitions?: Record<string, TransitionRule>;
|
||||
on?: Record<string, RawTransition>;
|
||||
}
|
||||
|
||||
export interface TransitionRule {
|
||||
export interface RawTransition {
|
||||
to: string;
|
||||
requirements?: RequirementRule[];
|
||||
events?: EventRule[];
|
||||
require?: {
|
||||
role?: string | string[];
|
||||
user?: string;
|
||||
};
|
||||
condition?: string; // JavaScript Expression string
|
||||
events?: RawEvent[];
|
||||
}
|
||||
|
||||
export interface RequirementRule {
|
||||
role?: string;
|
||||
user?: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface EventRule {
|
||||
type: 'notify' | 'webhook' | 'update_status';
|
||||
export interface RawEvent {
|
||||
type: 'notify' | 'webhook' | 'assign' | 'auto_action';
|
||||
target?: string;
|
||||
template?: string;
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. Interfaces for COMPILED Schema (Optimized for Runtime)
|
||||
// ==========================================
|
||||
export interface CompiledWorkflow {
|
||||
workflow: string;
|
||||
version: string | number;
|
||||
states: Record<string, WorkflowState>;
|
||||
version: number;
|
||||
initialState: string; // Optimize: เก็บชื่อ Initial State ไว้เลย ไม่ต้อง loop หา
|
||||
states: Record<string, CompiledState>;
|
||||
}
|
||||
|
||||
export interface CompiledState {
|
||||
terminal: boolean;
|
||||
transitions: Record<string, CompiledTransition>;
|
||||
}
|
||||
|
||||
export interface CompiledTransition {
|
||||
to: string;
|
||||
requirements: {
|
||||
roles: string[];
|
||||
userId?: string;
|
||||
};
|
||||
condition?: string;
|
||||
events: RawEvent[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowDslService {
|
||||
/**
|
||||
* คอมไพล์ DSL Input ให้เป็น Standard Execution Tree
|
||||
*/
|
||||
compile(dsl: any): CompiledWorkflow {
|
||||
if (!dsl || typeof dsl !== 'object') {
|
||||
throw new BadRequestException('DSL must be a valid JSON object.');
|
||||
}
|
||||
private readonly logger = new Logger(WorkflowDslService.name);
|
||||
|
||||
if (!dsl.states || !Array.isArray(dsl.states)) {
|
||||
throw new BadRequestException(
|
||||
'DSL syntax error: "states" array is required.',
|
||||
);
|
||||
}
|
||||
/**
|
||||
* [Compile Time]
|
||||
* แปลง Raw DSL เป็น Compiled Structure พร้อม Validation
|
||||
*/
|
||||
compile(dsl: RawWorkflowDSL): CompiledWorkflow {
|
||||
this.validateSchemaStructure(dsl);
|
||||
|
||||
const compiled: CompiledWorkflow = {
|
||||
workflow: dsl.workflow || 'UNKNOWN',
|
||||
workflow: dsl.workflow,
|
||||
version: dsl.version || 1,
|
||||
initialState: '',
|
||||
states: {},
|
||||
};
|
||||
|
||||
const stateMap = new Set<string>();
|
||||
const definedStates = new Set<string>(dsl.states.map((s) => s.name));
|
||||
let initialFound = false;
|
||||
|
||||
// 1. Process States
|
||||
for (const rawState of dsl.states) {
|
||||
if (!rawState.name) {
|
||||
throw new BadRequestException(
|
||||
'DSL syntax error: All states must have a "name".',
|
||||
);
|
||||
if (rawState.initial) {
|
||||
if (initialFound) {
|
||||
throw new BadRequestException(
|
||||
`DSL Error: Multiple initial states found (at "${rawState.name}").`,
|
||||
);
|
||||
}
|
||||
compiled.initialState = rawState.name;
|
||||
initialFound = true;
|
||||
}
|
||||
|
||||
stateMap.add(rawState.name);
|
||||
|
||||
const normalizedState: WorkflowState = {
|
||||
initial: !!rawState.initial,
|
||||
const compiledState: CompiledState = {
|
||||
terminal: !!rawState.terminal,
|
||||
transitions: {},
|
||||
};
|
||||
|
||||
// 2. Process Transitions
|
||||
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 || [],
|
||||
// Validation: Target state must exist
|
||||
if (!definedStates.has(rule.to)) {
|
||||
throw new BadRequestException(
|
||||
`DSL Error: State "${rawState.name}" transitions via "${action}" to unknown state "${rule.to}".`,
|
||||
);
|
||||
}
|
||||
|
||||
compiledState.transitions[action] = {
|
||||
to: rule.to,
|
||||
requirements: {
|
||||
roles: rule.require?.role
|
||||
? Array.isArray(rule.require.role)
|
||||
? rule.require.role
|
||||
: [rule.require.role]
|
||||
: [],
|
||||
userId: rule.require?.user,
|
||||
},
|
||||
condition: rule.condition,
|
||||
events: rule.events || [],
|
||||
};
|
||||
}
|
||||
} else if (!rawState.terminal) {
|
||||
this.logger.warn(
|
||||
`State "${rawState.name}" is not terminal but has no transitions.`,
|
||||
);
|
||||
}
|
||||
|
||||
compiled.states[rawState.name] = normalizedState;
|
||||
compiled.states[rawState.name] = compiledState;
|
||||
}
|
||||
|
||||
this.validateIntegrity(compiled, stateMap);
|
||||
if (!initialFound) {
|
||||
throw new BadRequestException('DSL Error: No initial state defined.');
|
||||
}
|
||||
|
||||
return compiled;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (state.transitions) {
|
||||
for (const [action, rule] of Object.entries(state.transitions)) {
|
||||
if (!stateMap.has(rule.to)) {
|
||||
throw new BadRequestException(
|
||||
`DSL Error: State "${stateName}" transitions via "${action}" to unknown state "${rule.to}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInitial) {
|
||||
throw new BadRequestException('DSL Error: No initial state defined.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Runtime]
|
||||
* ประมวลผล Action และคืนค่า State ถัดไป
|
||||
*/
|
||||
evaluate(
|
||||
compiled: CompiledWorkflow,
|
||||
currentState: string,
|
||||
action: string,
|
||||
context: any = {}, // Default empty object
|
||||
): { nextState: string; events: EventRule[] } {
|
||||
context: any = {},
|
||||
): { nextState: string; events: RawEvent[] } {
|
||||
const stateConfig = compiled.states[currentState];
|
||||
|
||||
// 1. Validate State Existence
|
||||
if (!stateConfig) {
|
||||
throw new BadRequestException(
|
||||
`Runtime Error: Current state "${currentState}" not found in definition.`,
|
||||
`Runtime Error: Current state "${currentState}" is invalid.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Check if terminal
|
||||
if (stateConfig.terminal) {
|
||||
throw new BadRequestException(
|
||||
`Runtime Error: Cannot transition from terminal state "${currentState}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const transition = stateConfig.transitions?.[action];
|
||||
|
||||
// 3. Find Transition
|
||||
const transition = stateConfig.transitions[action];
|
||||
if (!transition) {
|
||||
const allowed = Object.keys(stateConfig.transitions).join(', ');
|
||||
throw new BadRequestException(
|
||||
`Runtime Error: Action "${action}" is not allowed from state "${currentState}". Available actions: ${Object.keys(stateConfig.transitions || {}).join(', ')}`,
|
||||
`Invalid Action: "${action}" is not allowed from "${currentState}". Allowed: [${allowed}]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (transition.requirements && transition.requirements.length > 0) {
|
||||
this.checkRequirements(transition.requirements, context);
|
||||
// 4. Validate Requirements (RBAC)
|
||||
this.checkRequirements(transition.requirements, context);
|
||||
|
||||
// 5. Evaluate Condition (Dynamic Logic)
|
||||
if (transition.condition) {
|
||||
const isMet = this.evaluateCondition(transition.condition, context);
|
||||
if (!isMet) {
|
||||
throw new BadRequestException(
|
||||
'Condition Failed: The criteria for this transition are not met.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nextState: transition.to,
|
||||
events: transition.events || [],
|
||||
events: transition.events,
|
||||
};
|
||||
}
|
||||
|
||||
private checkRequirements(requirements: RequirementRule[], context: any) {
|
||||
const safeContext = context || {};
|
||||
const userRoles = safeContext.roles || [];
|
||||
const userId = safeContext.userId;
|
||||
// --------------------------------------------------------
|
||||
// Private Helpers
|
||||
// --------------------------------------------------------
|
||||
|
||||
const isAllowed = requirements.some((req) => {
|
||||
if (req.role) {
|
||||
return userRoles.includes(req.role);
|
||||
}
|
||||
if (req.user) {
|
||||
return userId === req.user;
|
||||
}
|
||||
// Future: Add Condition Logic Evaluation here
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
private validateSchemaStructure(dsl: any) {
|
||||
if (!dsl || typeof dsl !== 'object') {
|
||||
throw new BadRequestException('DSL must be a JSON object.');
|
||||
}
|
||||
if (!dsl.workflow || !dsl.states || !Array.isArray(dsl.states)) {
|
||||
throw new BadRequestException(
|
||||
'Access Denied: You do not meet the requirements for this action.',
|
||||
'DSL Error: Missing required fields (workflow, states).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private checkRequirements(
|
||||
req: CompiledTransition['requirements'],
|
||||
context: any,
|
||||
) {
|
||||
const userRoles: string[] = context.roles || [];
|
||||
const userId: string | number = context.userId;
|
||||
|
||||
// Check Roles (OR logic inside array)
|
||||
if (req.roles.length > 0) {
|
||||
const hasRole = req.roles.some((r) => userRoles.includes(r));
|
||||
if (!hasRole) {
|
||||
throw new BadRequestException(
|
||||
`Access Denied: Required roles [${req.roles.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Specific User
|
||||
if (req.userId && String(req.userId) !== String(userId)) {
|
||||
throw new BadRequestException('Access Denied: User mismatch.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate simple JS expression securely
|
||||
* NOTE: In production, use a safe parser like 'json-logic-js' or vm2
|
||||
* For this phase, we use a simple Function constructor with restricted scope.
|
||||
*/
|
||||
private evaluateCondition(expression: string, context: any): boolean {
|
||||
try {
|
||||
// Simple guard against malicious code (basic)
|
||||
if (expression.includes('process') || expression.includes('require')) {
|
||||
throw new Error('Unsafe expression detected');
|
||||
}
|
||||
|
||||
// Create a function that returns the expression result
|
||||
// "context" is available inside the expression
|
||||
const func = new Function('context', `return ${expression};`);
|
||||
return !!func(context);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Condition Error: "${expression}" -> ${error.message}`);
|
||||
return false; // Fail safe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,58 +5,103 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
||||
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
|
||||
import { GetAvailableActionsDto } from './dto/get-available-actions.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
// Services
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
|
||||
@ApiTags('Workflow Engine (DSL)')
|
||||
// DTOs
|
||||
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
||||
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import { WorkflowTransitionDto } from './dto/workflow-transition.dto';
|
||||
|
||||
// Guards & Decorators (อ้างอิงตามโครงสร้าง src/common ในแผนงาน)
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
|
||||
@ApiTags('Workflow Engine')
|
||||
@ApiBearerAuth() // ระบุว่าต้องใช้ Token ใน Swagger
|
||||
@Controller('workflow-engine')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RbacGuard) // บังคับ Login และตรวจสอบสิทธิ์ทุก Request
|
||||
export class WorkflowEngineController {
|
||||
constructor(private readonly workflowService: WorkflowEngineService) {}
|
||||
|
||||
// =================================================================
|
||||
// Definition Management (Admin / Developer)
|
||||
// =================================================================
|
||||
|
||||
@Post('definitions')
|
||||
@ApiOperation({ summary: 'Create or Update Workflow Definition (DSL)' })
|
||||
@ApiResponse({ status: 201, description: 'Workflow compiled and saved.' })
|
||||
@ApiOperation({ summary: 'สร้าง Workflow Definition ใหม่ (Auto Versioning)' })
|
||||
@ApiResponse({ status: 201, description: 'Created successfully' })
|
||||
// ใช้ Permission 'system.manage_all' (Admin) หรือสร้าง permission ใหม่ 'workflow.manage' ในอนาคต
|
||||
@RequirePermission('system.manage_all')
|
||||
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) {
|
||||
return this.workflowService.getAvailableActions(
|
||||
query.workflow_code,
|
||||
query.current_state,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('definitions/:id')
|
||||
@ApiOperation({
|
||||
summary: 'Update workflow status or details (DSL Re-compile)',
|
||||
})
|
||||
@ApiOperation({ summary: 'แก้ไข Workflow Definition (Re-compile DSL)' })
|
||||
@RequirePermission('system.manage_all')
|
||||
async updateDefinition(
|
||||
@Param('id', ParseUUIDPipe) id: string, // เพิ่ม ParseUUIDPipe เพื่อ Validate ID
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateWorkflowDefinitionDto,
|
||||
) {
|
||||
return this.workflowService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('evaluate')
|
||||
@ApiOperation({ summary: 'ทดสอบ Logic Workflow (Dry Run) ไม่บันทึกข้อมูล' })
|
||||
@RequirePermission('system.manage_all')
|
||||
async evaluate(@Body() dto: EvaluateWorkflowDto) {
|
||||
return this.workflowService.evaluate(dto);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Runtime Engine (User Actions)
|
||||
// =================================================================
|
||||
|
||||
@Post('instances/:id/transition')
|
||||
@ApiOperation({ summary: 'สั่งเปลี่ยนสถานะเอกสาร (User Action)' })
|
||||
@ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' })
|
||||
// Permission จะถูกตรวจสอบ Dynamic ภายใน Service ตาม State ของ Workflow แต่ขั้นต้นต้องมีสิทธิ์ทำงาน Workflow
|
||||
@RequirePermission('workflow.action_review')
|
||||
async processTransition(
|
||||
@Param('id') instanceId: string,
|
||||
@Body() dto: WorkflowTransitionDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
// ดึง User ID จาก Token (req.user มาจาก JwtStrategy)
|
||||
const userId = req.user?.userId;
|
||||
|
||||
return this.workflowService.processTransition(
|
||||
instanceId,
|
||||
dto.action,
|
||||
userId,
|
||||
dto.comment,
|
||||
dto.payload,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('instances/:id/actions')
|
||||
@ApiOperation({
|
||||
summary: 'ดึงรายการปุ่ม Action ที่สามารถกดได้ ณ สถานะปัจจุบัน',
|
||||
})
|
||||
@RequirePermission('document.view') // ผู้ที่มีสิทธิ์ดูเอกสาร ควรดู Action ได้
|
||||
async getAvailableActions(@Param('id') instanceId: string) {
|
||||
// Note: Logic การดึง Action ตาม Instance ID จะถูก Implement ใน Task ถัดไป
|
||||
return { message: 'Pending implementation in Service layer' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,31 @@
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity'; // [New]
|
||||
import { WorkflowInstance } from './entities/workflow-instance.entity'; // [New]
|
||||
import { WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEngineController } from './workflow-engine.controller';
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
|
||||
// Entities
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
import { WorkflowInstance } from './entities/workflow-instance.entity';
|
||||
|
||||
// Services
|
||||
import { WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
import { WorkflowEventService } from './workflow-event.service'; // [NEW]
|
||||
|
||||
// Controllers
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WorkflowEngineController } from './workflow-engine.controller';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance, // [New]
|
||||
WorkflowHistory, // [New]
|
||||
WorkflowInstance,
|
||||
WorkflowHistory,
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [WorkflowEngineController],
|
||||
providers: [WorkflowEngineService, WorkflowDslService],
|
||||
exports: [WorkflowEngineService],
|
||||
providers: [WorkflowEngineService, WorkflowDslService, WorkflowEventService],
|
||||
exports: [WorkflowEngineService], // Export Service ให้ Module อื่น (Correspondence, RFA) เรียกใช้
|
||||
})
|
||||
export class WorkflowEngineModule {}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dt
|
||||
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import { CompiledWorkflow, WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEventService } from './workflow-event.service'; // [NEW] Import Event Service
|
||||
|
||||
// Legacy Interface (Backward Compatibility)
|
||||
export enum WorkflowAction {
|
||||
@@ -49,6 +50,7 @@ export class WorkflowEngineService {
|
||||
@InjectRepository(WorkflowHistory)
|
||||
private readonly historyRepo: Repository<WorkflowHistory>,
|
||||
private readonly dslService: WorkflowDslService,
|
||||
private readonly eventService: WorkflowEventService, // [NEW] Inject Service
|
||||
private readonly dataSource: DataSource, // ใช้สำหรับ Transaction
|
||||
) {}
|
||||
|
||||
@@ -166,9 +168,9 @@ export class WorkflowEngineService {
|
||||
|
||||
// 2. หา Initial State จาก Compiled Structure
|
||||
const compiled: CompiledWorkflow = definition.compiled;
|
||||
const initialState = Object.keys(compiled.states).find(
|
||||
(key) => compiled.states[key].initial,
|
||||
);
|
||||
// [FIX] ใช้ initialState จาก Root Property โดยตรง (ตามที่ Optimize ใน DSL Service)
|
||||
// เพราะ CompiledState ใน states map ไม่มี property 'initial' แล้ว
|
||||
const initialState = compiled.initialState;
|
||||
|
||||
if (!initialState) {
|
||||
throw new BadRequestException(
|
||||
@@ -193,6 +195,25 @@ export class WorkflowEngineService {
|
||||
return savedInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงข้อมูล Workflow Instance ตาม ID
|
||||
* ใช้สำหรับการตรวจสอบสถานะหรือซิงค์ข้อมูลกลับไปยัง Module หลัก
|
||||
*/
|
||||
async getInstanceById(instanceId: string): Promise<WorkflowInstance> {
|
||||
const instance = await this.instanceRepo.findOne({
|
||||
where: { id: instanceId },
|
||||
relations: ['definition'],
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
throw new NotFoundException(
|
||||
`Workflow Instance "${instanceId}" not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดำเนินการเปลี่ยนสถานะ (Transition) ของ Instance จริงแบบ Transactional
|
||||
*/
|
||||
@@ -207,6 +228,9 @@ export class WorkflowEngineService {
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
let eventsToDispatch: any[] = [];
|
||||
let updatedContext: any = {};
|
||||
|
||||
try {
|
||||
// 1. Lock Instance เพื่อป้องกัน Race Condition (Pessimistic Write Lock)
|
||||
const instance = await queryRunner.manager.findOne(WorkflowInstance, {
|
||||
@@ -268,25 +292,29 @@ export class WorkflowEngineService {
|
||||
});
|
||||
await queryRunner.manager.save(history);
|
||||
|
||||
// 5. Trigger Events (Integration Point)
|
||||
// ในอนาคตสามารถ Inject NotificationService มาเรียกตรงนี้ได้
|
||||
if (evaluation.events && evaluation.events.length > 0) {
|
||||
this.logger.log(
|
||||
`Triggering ${evaluation.events.length} events for instance ${instanceId}`,
|
||||
);
|
||||
// await this.eventHandler.handle(evaluation.events);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// [NEW] เก็บค่าไว้ Dispatch หลัง Commit
|
||||
eventsToDispatch = evaluation.events;
|
||||
updatedContext = context;
|
||||
|
||||
this.logger.log(
|
||||
`Transition: ${instanceId} [${fromState}] --${action}--> [${toState}] by User:${userId}`,
|
||||
);
|
||||
|
||||
// [NEW] Dispatch Events (Async) ผ่าน WorkflowEventService
|
||||
if (eventsToDispatch && eventsToDispatch.length > 0) {
|
||||
this.eventService.dispatchEvents(
|
||||
instance.id,
|
||||
eventsToDispatch,
|
||||
updatedContext,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nextState: toState,
|
||||
events: evaluation.events,
|
||||
events: eventsToDispatch,
|
||||
isCompleted: instance.status === WorkflowStatus.COMPLETED,
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// File: src/modules/workflow-engine/workflow-event.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { RawEvent } from './workflow-dsl.service';
|
||||
|
||||
// Interface สำหรับ External Services ที่จะมารับ Event ต่อ
|
||||
// (ในอนาคตควรใช้ NestJS Event Emitter เพื่อ Decouple อย่างสมบูรณ์)
|
||||
export interface WorkflowEventHandler {
|
||||
handleNotification(
|
||||
target: string,
|
||||
template: string,
|
||||
payload: any,
|
||||
): Promise<void>;
|
||||
handleWebhook(url: string, payload: any): Promise<void>;
|
||||
handleAutoAction(instanceId: string, action: string): Promise<void>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowEventService {
|
||||
private readonly logger = new Logger(WorkflowEventService.name);
|
||||
|
||||
// สามารถ Inject NotificationService หรือ HttpService เข้ามาได้ตรงนี้
|
||||
// constructor(private readonly notificationService: NotificationService) {}
|
||||
|
||||
/**
|
||||
* ประมวลผลรายการ Events ที่เกิดจากการเปลี่ยนสถานะ
|
||||
*/
|
||||
async dispatchEvents(
|
||||
instanceId: string,
|
||||
events: RawEvent[],
|
||||
context: Record<string, any>,
|
||||
) {
|
||||
if (!events || events.length === 0) return;
|
||||
|
||||
this.logger.log(
|
||||
`Dispatching ${events.length} events for Instance ${instanceId}`,
|
||||
);
|
||||
|
||||
// ทำแบบ Async ไม่รอผล (Fire-and-forget) เพื่อไม่ให้กระทบ Response Time ของ User
|
||||
Promise.allSettled(
|
||||
events.map((event) =>
|
||||
this.processSingleEvent(instanceId, event, context),
|
||||
),
|
||||
).then((results) => {
|
||||
// Log errors if any
|
||||
results.forEach((res, idx) => {
|
||||
if (res.status === 'rejected') {
|
||||
this.logger.error(`Failed to process event [${idx}]: ${res.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async processSingleEvent(
|
||||
instanceId: string,
|
||||
event: RawEvent,
|
||||
context: any,
|
||||
) {
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'notify':
|
||||
await this.handleNotify(event, context);
|
||||
break;
|
||||
case 'webhook':
|
||||
await this.handleWebhook(event, context);
|
||||
break;
|
||||
case 'auto_action':
|
||||
// Logic สำหรับ Auto Transition (เช่น ถ้าผ่านเงื่อนไข ให้ไปต่อเลย)
|
||||
this.logger.log(`Auto Action triggered for ${instanceId}`);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`Unknown event type: ${event.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing event ${event.type}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
private async handleNotify(event: RawEvent, context: any) {
|
||||
// Mockup: ในของจริงจะเรียก NotificationService.send()
|
||||
// const recipients = this.resolveRecipients(event.target, context);
|
||||
this.logger.log(
|
||||
`[EVENT] Notify target: "${event.target}" | Template: "${event.template}"`,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleWebhook(event: RawEvent, context: any) {
|
||||
// Mockup: เรียก HttpService.post()
|
||||
this.logger.log(
|
||||
`[EVENT] Webhook to: "${event.target}" | Payload: ${JSON.stringify(event.payload)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user