251129:1700 update to 1.4.5
This commit is contained in:
125
backend/src/Workflow DSL Specification.md
Normal file
125
backend/src/Workflow DSL Specification.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# **Workflow DSL Specification v1.0**
|
||||
|
||||
เอกสารนี้ระบุโครงสร้างภาษา (Domain-Specific Language) สำหรับกำหนด Business Logic ของการเดินเอกสารในระบบ LCBP3-DMS
|
||||
|
||||
## **1\. โครงสร้างหลัก (Root Structure)**
|
||||
|
||||
ไฟล์ Definition ต้องอยู่ในรูปแบบ YAML หรือ JSON โดยมีโครงสร้างดังนี้:
|
||||
|
||||
```json
|
||||
workflow: "RFA_FLOW" # รหัส Workflow (Unique)
|
||||
version: 1 # เวอร์ชันของ Logic
|
||||
description: "RFA Approval Process" # คำอธิบาย
|
||||
|
||||
# รายการสถานะทั้งหมดที่เป็นไปได้
|
||||
states:
|
||||
- name: "DRAFT" # ชื่อสถานะ (Case-sensitive)
|
||||
initial: true # เป็นสถานะเริ่มต้น (ต้องมี 1 สถานะ)
|
||||
on: # รายการ Action ที่ทำได้จากสถานะนี้
|
||||
SUBMIT: # ชื่อ Action (ปุ่มที่ User กด)
|
||||
to: "IN_REVIEW" # สถานะปลายทาง
|
||||
require: # (Optional) เงื่อนไขสิทธิ์
|
||||
role: "EDITOR"
|
||||
events: # (Optional) เหตุการณ์ที่จะเกิดขึ้นเมื่อเปลี่ยนสถานะ
|
||||
- type: "notify"
|
||||
target: "reviewer"
|
||||
|
||||
- name: "IN_REVIEW"
|
||||
on:
|
||||
APPROVE:
|
||||
to: "APPROVED"
|
||||
condition: "context.amount < 1000000" # (Optional) JS Expression
|
||||
REJECT:
|
||||
to: "DRAFT"
|
||||
events:
|
||||
- type: "notify"
|
||||
target: "creator"
|
||||
|
||||
- name: "APPROVED"
|
||||
terminal: true # เป็นสถานะจบ (ไม่สามารถไปต่อได้)
|
||||
```
|
||||
|
||||
## **2. รายละเอียด Field (Field Definitions)**
|
||||
|
||||
### **2.1 State Object**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------- | :------ | :------- | :--------------------------------------------- |
|
||||
| name | string | Yes | ชื่อสถานะ (Unique Key) |
|
||||
| initial | boolean | No | ระบุว่าเป็นจุดเริ่มต้น (ต้องมี 1 state ในระบบ) |
|
||||
| terminal | boolean | No | ระบุว่าเป็นจุดสิ้นสุด |
|
||||
| on | object | No | Map ของ Action -> Transition Rule |
|
||||
|
||||
### **2.2 Transition Rule Object**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :-------- | :----- | :------- | :-------------------------------------- |
|
||||
| to | string | Yes | ชื่อสถานะปลายทาง |
|
||||
| require | object | No | เงื่อนไข Role/User |
|
||||
| condition | string | No | JavaScript Expression (return boolean) |
|
||||
| events | array | No | Side-effects ที่จะทำงานหลังเปลี่ยนสถานะ |
|
||||
|
||||
### **2.3 Requirements Object**
|
||||
|
||||
| Field | Type | Description |
|
||||
| :---- | :----- | :------------------------------------------ |
|
||||
| role | string | User ต้องมี Role นี้ (เช่น PROJECT_MANAGER) |
|
||||
| user | string | User ต้องมี ID นี้ (Hard-code) |
|
||||
|
||||
### **2.4 Event Object**
|
||||
|
||||
| Field | Type | Description |
|
||||
| :------- | :----- | :----------------------------------------- |
|
||||
| type | string | notify, webhook, update_status |
|
||||
| target | string | ผู้รับ (เช่น creator, assignee, หรือ Role) |
|
||||
| template | string | รหัส Template ข้อความ |
|
||||
|
||||
## **3\. ตัวอย่างการใช้งานจริง (Real-world Examples)**
|
||||
|
||||
### **ตัวอย่าง: RFA Approval Flow**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow": "RFA_STD",
|
||||
"version": 1,
|
||||
"states": [
|
||||
{
|
||||
"name": "DRAFT",
|
||||
"initial": true,
|
||||
"on": {
|
||||
"SUBMIT": {
|
||||
"to": "CONSULTANT_REVIEW",
|
||||
"require": { "role": "CONTRACTOR" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CONSULTANT_REVIEW",
|
||||
"on": {
|
||||
"APPROVE_1": {
|
||||
"to": "OWNER_REVIEW",
|
||||
"condition": "context.priority === 'HIGH'"
|
||||
},
|
||||
"APPROVE_2": {
|
||||
"to": "APPROVED",
|
||||
"condition": "context.priority === 'NORMAL'"
|
||||
},
|
||||
"REJECT": {
|
||||
"to": "DRAFT"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OWNER_REVIEW",
|
||||
"on": {
|
||||
"APPROVE": { "to": "APPROVED" },
|
||||
"REJECT": { "to": "CONSULTANT_REVIEW" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "APPROVED",
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
67
backend/src/database/seeds/run-seed.ts
Normal file
67
backend/src/database/seeds/run-seed.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { config } from 'dotenv';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { seedWorkflowDefinitions } from '../seeds/workflow-definitions.seed'; // Import ฟังก์ชัน Seed ที่คุณมี
|
||||
// Import Entities ที่เกี่ยวข้อง
|
||||
import { WorkflowDefinition } from '../../modules/workflow-engine/entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from '../../modules/workflow-engine/entities/workflow-history.entity';
|
||||
import { WorkflowInstance } from '../../modules/workflow-engine/entities/workflow-instance.entity';
|
||||
|
||||
// โหลด Environment Variables (.env)
|
||||
config();
|
||||
|
||||
const runSeed = async () => {
|
||||
// ตั้งค่าการเชื่อมต่อฐานข้อมูล (ควรตรงกับ docker-compose หรือ .env ของคุณ)
|
||||
const dataSource = new DataSource({
|
||||
type: 'mariadb',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
username: process.env.DB_USERNAME || 'root',
|
||||
password: process.env.DB_PASSWORD || 'Center#2025',
|
||||
database: process.env.DB_DATABASE || 'lcbp3_dev',
|
||||
// สำคัญ: ต้องใส่ Entities ที่เกี่ยวข้องทั้งหมดเพื่อให้ TypeORM รู้จัก
|
||||
entities: [
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance,
|
||||
WorkflowHistory,
|
||||
// ใส่ Entity อื่นๆ ถ้าจำเป็น หรือใช้ path pattern: __dirname + '/../../modules/**/*.entity{.ts,.js}'
|
||||
],
|
||||
synchronize: false, // ห้ามใช้ true บน Production
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('🔌 Connecting to database...');
|
||||
await dataSource.initialize();
|
||||
console.log('✅ Database connected.');
|
||||
|
||||
console.log('🌱 Running Seeds...');
|
||||
await seedWorkflowDefinitions(dataSource);
|
||||
console.log('✅ Seeding completed successfully.');
|
||||
} catch (error) {
|
||||
console.error('❌ Error during seeding:', error);
|
||||
} finally {
|
||||
if (dataSource.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
console.log('🔌 Database connection closed.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
runSeed();
|
||||
|
||||
/*
|
||||
npx ts-node -r tsconfig-paths/register src/database/run-seed.ts
|
||||
|
||||
**หรือเพิ่มใน `package.json` (แนะนำ):**
|
||||
คุณสามารถเพิ่ม script ใน `package.json` เพื่อให้เรียกใช้ได้ง่ายขึ้นในอนาคต:
|
||||
|
||||
"scripts": {
|
||||
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
|
||||
}
|
||||
|
||||
http://googleusercontent.com/immersive_entry_chip/1
|
||||
|
||||
### 💡 ข้อควรระวัง
|
||||
1. **Environment Variables:** ตรวจสอบให้แน่ใจว่าค่า Config (Host, User, Password) ในไฟล์ `run-seed.ts` หรือ `.env` นั้นถูกต้องและตรงกับ Docker Container ที่กำลังรันอยู่
|
||||
2. **Entities:** หากฟังก์ชัน Seed มีการเรียกใช้ Entity อื่นนอกเหนือจาก `WorkflowDefinition` ต้องนำมาใส่ใน `entities: [...]` ของ `DataSource` ให้ครบ ไม่อย่างนั้นจะเจอ Error `RepositoryNotFoundError`
|
||||
|
||||
*/
|
||||
@@ -10,26 +10,41 @@ export const seedWorkflowDefinitions = async (dataSource: DataSource) => {
|
||||
|
||||
// 1. RFA Workflow (Standard)
|
||||
const rfaDsl = {
|
||||
workflow: 'RFA',
|
||||
workflow: 'RFA_FLOW_V1', // [FIX] เปลี่ยนชื่อให้ตรงกับค่าใน RfaWorkflowService
|
||||
version: 1,
|
||||
description: 'Standard RFA Approval Workflow',
|
||||
states: [
|
||||
{
|
||||
name: 'DRAFT',
|
||||
initial: true,
|
||||
on: { SUBMIT: { to: 'IN_REVIEW', requirements: [{ role: 'Editor' }] } },
|
||||
on: {
|
||||
SUBMIT: {
|
||||
to: 'IN_REVIEW',
|
||||
require: { role: 'Editor' }, // [FIX] แก้ไข Syntax เป็น Object
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'IN_REVIEW',
|
||||
on: {
|
||||
APPROVE: {
|
||||
to: 'APPROVED',
|
||||
requirements: [{ role: 'Contract Admin' }],
|
||||
APPROVE_1: {
|
||||
to: 'APPROVED', // [FIX] ชี้ไปที่ State ที่มีอยู่จริง
|
||||
require: { role: 'Contract Admin' },
|
||||
condition: "context.priority === 'HIGH'",
|
||||
},
|
||||
APPROVE_2: {
|
||||
to: 'APPROVED', // [FIX] ชี้ไปที่ State ที่มีอยู่จริง
|
||||
require: { role: 'Contract Admin' },
|
||||
condition: "context.priority === 'NORMAL'",
|
||||
},
|
||||
REJECT: {
|
||||
to: 'REJECTED',
|
||||
requirements: [{ role: 'Contract Admin' }],
|
||||
require: { role: 'Contract Admin' },
|
||||
},
|
||||
COMMENT: { to: 'DRAFT', requirements: [{ role: 'Contract Admin' }] }, // ส่งกลับแก้ไข
|
||||
COMMENT: {
|
||||
to: 'DRAFT',
|
||||
require: { role: 'Contract Admin' },
|
||||
}, // ส่งกลับแก้ไข
|
||||
},
|
||||
},
|
||||
{ name: 'APPROVED', terminal: true },
|
||||
@@ -39,18 +54,27 @@ export const seedWorkflowDefinitions = async (dataSource: DataSource) => {
|
||||
|
||||
// 2. Circulation Workflow
|
||||
const circulationDsl = {
|
||||
workflow: 'CIRCULATION',
|
||||
workflow: 'CIRCULATION_INTERNAL_V1', // [FIX] เปลี่ยนชื่อให้ตรงกับค่าใน CirculationWorkflowService
|
||||
version: 1,
|
||||
description: 'Internal Document Circulation',
|
||||
states: [
|
||||
{
|
||||
name: 'OPEN',
|
||||
initial: true,
|
||||
on: { SEND: { to: 'IN_REVIEW' } },
|
||||
on: {
|
||||
START: {
|
||||
// [FIX] เปลี่ยนชื่อ Action ให้ตรงกับที่ Service เรียกใช้ ('START')
|
||||
to: 'IN_REVIEW',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'IN_REVIEW',
|
||||
on: {
|
||||
COMPLETE: { to: 'COMPLETED' }, // เมื่อทุกคนตอบครบ
|
||||
COMPLETE_TASK: {
|
||||
// [FIX] เปลี่ยนให้สอดคล้องกับ Action ที่ใช้จริง
|
||||
to: 'COMPLETED',
|
||||
},
|
||||
CANCEL: { to: 'CANCELLED' },
|
||||
},
|
||||
},
|
||||
@@ -59,24 +83,60 @@ export const seedWorkflowDefinitions = async (dataSource: DataSource) => {
|
||||
],
|
||||
};
|
||||
|
||||
const workflows = [rfaDsl, circulationDsl];
|
||||
// 3. Correspondence Workflow (Optional - ถ้ามี)
|
||||
const correspondenceDsl = {
|
||||
workflow: 'CORRESPONDENCE_FLOW_V1',
|
||||
version: 1,
|
||||
description: 'Standard Correspondence Routing',
|
||||
states: [
|
||||
{
|
||||
name: 'DRAFT',
|
||||
initial: true,
|
||||
on: { SUBMIT: { to: 'IN_REVIEW' } },
|
||||
},
|
||||
{
|
||||
name: 'IN_REVIEW',
|
||||
on: {
|
||||
APPROVE: { to: 'APPROVED' },
|
||||
REJECT: { to: 'REJECTED' },
|
||||
},
|
||||
},
|
||||
{ name: 'APPROVED', terminal: true },
|
||||
{ name: 'REJECTED', terminal: true },
|
||||
],
|
||||
};
|
||||
|
||||
const workflows = [rfaDsl, circulationDsl, correspondenceDsl];
|
||||
|
||||
for (const dsl of workflows) {
|
||||
const exists = await repo.findOne({
|
||||
where: { workflow_code: dsl.workflow, version: dsl.version },
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
const compiled = dslService.compile(dsl);
|
||||
await repo.save(
|
||||
repo.create({
|
||||
workflow_code: dsl.workflow,
|
||||
version: dsl.version,
|
||||
dsl: dsl,
|
||||
compiled: compiled,
|
||||
is_active: true,
|
||||
}),
|
||||
try {
|
||||
// Compile เพื่อ Validate และ Normalize ก่อนบันทึก
|
||||
// cast as any เพื่อ bypass type checking ตอน seed raw data
|
||||
const compiled = dslService.compile(dsl as any);
|
||||
|
||||
await repo.save(
|
||||
repo.create({
|
||||
workflow_code: dsl.workflow,
|
||||
version: dsl.version,
|
||||
description: dsl.description,
|
||||
dsl: dsl,
|
||||
compiled: compiled,
|
||||
is_active: true,
|
||||
}),
|
||||
);
|
||||
console.log(`✅ Seeded Workflow: ${dsl.workflow} v${dsl.version}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to seed workflow ${dsl.workflow}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`⏭️ Workflow already exists: ${dsl.workflow} v${dsl.version}`,
|
||||
);
|
||||
console.log(`✅ Seeded Workflow: ${dsl.workflow} v${dsl.version}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
164
backend/src/modules/circulation/circulation-workflow.service.ts
Normal file
164
backend/src/modules/circulation/circulation-workflow.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// File: src/modules/circulation/circulation-workflow.service.ts
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
// Modules
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
|
||||
// Entities
|
||||
import { CirculationStatusCode } from './entities/circulation-status-code.entity';
|
||||
import { Circulation } from './entities/circulation.entity';
|
||||
|
||||
// DTOs
|
||||
import { WorkflowTransitionDto } from '../workflow-engine/dto/workflow-transition.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CirculationWorkflowService {
|
||||
private readonly logger = new Logger(CirculationWorkflowService.name);
|
||||
private readonly WORKFLOW_CODE = 'CIRCULATION_INTERNAL_V1';
|
||||
|
||||
constructor(
|
||||
private readonly workflowEngine: WorkflowEngineService,
|
||||
@InjectRepository(Circulation)
|
||||
private readonly circulationRepo: Repository<Circulation>,
|
||||
@InjectRepository(CirculationStatusCode)
|
||||
private readonly statusRepo: Repository<CirculationStatusCode>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* เริ่มต้นใบเวียน (Start Circulation)
|
||||
* ปกติจะเริ่มเมื่อสร้าง Circulation หรือเมื่อกดส่ง
|
||||
*/
|
||||
async startCirculation(circulationId: number, userId: number) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const circulation = await this.circulationRepo.findOne({
|
||||
where: { id: circulationId },
|
||||
});
|
||||
|
||||
if (!circulation) {
|
||||
throw new NotFoundException(
|
||||
`Circulation ID ${circulationId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Context อาจประกอบด้วย Department หรือ Priority
|
||||
const context = {
|
||||
organizationId: circulation.organization,
|
||||
creatorId: userId,
|
||||
};
|
||||
|
||||
// Create Instance (Entity Type = 'circulation')
|
||||
const instance = await this.workflowEngine.createInstance(
|
||||
this.WORKFLOW_CODE,
|
||||
'circulation',
|
||||
circulation.id.toString(),
|
||||
context,
|
||||
);
|
||||
|
||||
// Auto start (OPEN -> IN_REVIEW)
|
||||
const transitionResult = await this.workflowEngine.processTransition(
|
||||
instance.id,
|
||||
'START',
|
||||
userId,
|
||||
'Start Circulation Process',
|
||||
{},
|
||||
);
|
||||
|
||||
// Sync Status
|
||||
await this.syncStatus(
|
||||
circulation,
|
||||
transitionResult.nextState,
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
currentState: transitionResult.nextState,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(`Failed to start circulation: ${error}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* รับทราบ/ดำเนินการในใบเวียน (Acknowledge / Action)
|
||||
*/
|
||||
async processAction(
|
||||
instanceId: string,
|
||||
userId: number,
|
||||
dto: WorkflowTransitionDto,
|
||||
) {
|
||||
// ส่งให้ Engine
|
||||
const result = await this.workflowEngine.processTransition(
|
||||
instanceId,
|
||||
dto.action,
|
||||
userId,
|
||||
dto.comment,
|
||||
dto.payload,
|
||||
);
|
||||
|
||||
// Sync Status กลับ
|
||||
const instance = await this.workflowEngine.getInstanceById(instanceId);
|
||||
if (instance && instance.entityType === 'circulation') {
|
||||
const circulation = await this.circulationRepo.findOne({
|
||||
where: { id: parseInt(instance.entityId) },
|
||||
});
|
||||
if (circulation) {
|
||||
await this.syncStatus(circulation, result.nextState);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Map Workflow State -> Circulation Status (OPEN, IN_REVIEW, COMPLETED)
|
||||
*/
|
||||
private async syncStatus(
|
||||
circulation: Circulation,
|
||||
workflowState: string,
|
||||
queryRunner?: any,
|
||||
) {
|
||||
const statusMap: Record<string, string> = {
|
||||
DRAFT: 'OPEN',
|
||||
ROUTING: 'IN_REVIEW',
|
||||
COMPLETED: 'COMPLETED',
|
||||
CANCELLED: 'CANCELLED',
|
||||
};
|
||||
|
||||
const targetCode = statusMap[workflowState] || 'IN_REVIEW';
|
||||
|
||||
// เนื่องจาก circulation เก็บ status_code เป็น String ในตารางเลย (ตาม Schema v1.4.4)
|
||||
// หรืออาจเป็น Relation ID ขึ้นอยู่กับ Implementation จริง
|
||||
// สมมติว่าเป็น String Code ตาม Schema:
|
||||
|
||||
circulation.statusCode = targetCode;
|
||||
|
||||
// ถ้าจบแล้ว ให้ลงเวลาปิด
|
||||
if (targetCode === 'COMPLETED') {
|
||||
circulation.closedAt = new Date();
|
||||
}
|
||||
|
||||
const manager = queryRunner
|
||||
? queryRunner.manager
|
||||
: this.circulationRepo.manager;
|
||||
await manager.save(circulation);
|
||||
|
||||
this.logger.log(
|
||||
`Synced Circulation #${circulation.id}: State=${workflowState} -> Status=${targetCode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Circulation } from './entities/circulation.entity';
|
||||
import { CirculationRouting } from './entities/circulation-routing.entity';
|
||||
import { CirculationStatusCode } from './entities/circulation-status-code.entity';
|
||||
import { Circulation } from './entities/circulation.entity';
|
||||
|
||||
import { CirculationService } from './circulation.service';
|
||||
import { CirculationController } from './circulation.controller';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
||||
import { CirculationWorkflowService } from './circulation-workflow.service';
|
||||
import { CirculationController } from './circulation.controller';
|
||||
import { CirculationService } from './circulation.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -17,9 +19,10 @@ import { UserModule } from '../user/user.module';
|
||||
CirculationStatusCode,
|
||||
]),
|
||||
UserModule,
|
||||
WorkflowEngineModule,
|
||||
],
|
||||
controllers: [CirculationController],
|
||||
providers: [CirculationService],
|
||||
providers: [CirculationService, CirculationWorkflowService],
|
||||
exports: [CirculationService],
|
||||
})
|
||||
export class CirculationModule {}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// File: src/modules/correspondence/correspondence-workflow.service.ts
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { WorkflowTransitionDto } from '../workflow-engine/dto/workflow-transition.dto';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
|
||||
import { Correspondence } from './entities/correspondence.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CorrespondenceWorkflowService {
|
||||
private readonly logger = new Logger(CorrespondenceWorkflowService.name);
|
||||
private readonly WORKFLOW_CODE = 'CORRESPONDENCE_FLOW_V1';
|
||||
|
||||
constructor(
|
||||
private readonly workflowEngine: WorkflowEngineService,
|
||||
@InjectRepository(Correspondence)
|
||||
private readonly correspondenceRepo: Repository<Correspondence>,
|
||||
@InjectRepository(CorrespondenceRevision)
|
||||
private readonly revisionRepo: Repository<CorrespondenceRevision>,
|
||||
@InjectRepository(CorrespondenceStatus)
|
||||
private readonly statusRepo: Repository<CorrespondenceStatus>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async submitWorkflow(
|
||||
correspondenceId: number,
|
||||
userId: number,
|
||||
note?: string,
|
||||
) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const revision = await this.revisionRepo.findOne({
|
||||
// ✅ FIX: CamelCase (correspondenceId, isCurrent)
|
||||
where: { correspondenceId: correspondenceId, isCurrent: true },
|
||||
relations: ['correspondence'],
|
||||
});
|
||||
|
||||
if (!revision) {
|
||||
throw new NotFoundException(
|
||||
`Correspondence Revision for ID ${correspondenceId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ FIX: Check undefined before access
|
||||
if (!revision.correspondence) {
|
||||
throw new NotFoundException(`Correspondence relation not found`);
|
||||
}
|
||||
|
||||
const context = {
|
||||
// ✅ FIX: CamelCase (projectId, correspondenceTypeId)
|
||||
projectId: revision.correspondence.projectId,
|
||||
typeId: revision.correspondence.correspondenceTypeId,
|
||||
ownerId: userId,
|
||||
amount: 0,
|
||||
priority: 'NORMAL',
|
||||
};
|
||||
|
||||
const instance = await this.workflowEngine.createInstance(
|
||||
this.WORKFLOW_CODE,
|
||||
'correspondence_revision',
|
||||
revision.id.toString(),
|
||||
context,
|
||||
);
|
||||
|
||||
const transitionResult = await this.workflowEngine.processTransition(
|
||||
instance.id,
|
||||
'SUBMIT',
|
||||
userId,
|
||||
note || 'Initial Submission',
|
||||
{},
|
||||
);
|
||||
|
||||
await this.syncStatus(revision, transitionResult.nextState, queryRunner);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
currentState: transitionResult.nextState,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(`Failed to submit workflow: ${error}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
async processAction(
|
||||
instanceId: string,
|
||||
userId: number,
|
||||
dto: WorkflowTransitionDto,
|
||||
) {
|
||||
const result = await this.workflowEngine.processTransition(
|
||||
instanceId,
|
||||
dto.action,
|
||||
userId,
|
||||
dto.comment,
|
||||
dto.payload,
|
||||
);
|
||||
|
||||
// ✅ FIX: Method exists now
|
||||
const instance = await this.workflowEngine.getInstanceById(instanceId);
|
||||
|
||||
if (instance && instance.entityType === 'correspondence_revision') {
|
||||
const revision = await this.revisionRepo.findOne({
|
||||
where: { id: parseInt(instance.entityId) },
|
||||
});
|
||||
if (revision) {
|
||||
await this.syncStatus(revision, result.nextState);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async syncStatus(
|
||||
revision: CorrespondenceRevision,
|
||||
workflowState: string,
|
||||
queryRunner?: any,
|
||||
) {
|
||||
const statusMap: Record<string, string> = {
|
||||
DRAFT: 'DRAFT',
|
||||
IN_REVIEW: 'SUBOWN',
|
||||
APPROVED: 'CLBOWN',
|
||||
REJECTED: 'CCBOWN',
|
||||
};
|
||||
|
||||
const targetCode = statusMap[workflowState] || 'DRAFT';
|
||||
|
||||
const status = await this.statusRepo.findOne({
|
||||
where: { statusCode: targetCode }, // ✅ FIX: CamelCase
|
||||
});
|
||||
|
||||
if (status) {
|
||||
// ✅ FIX: CamelCase (correspondenceStatusId)
|
||||
revision.statusId = status.id;
|
||||
|
||||
const manager = queryRunner
|
||||
? queryRunner.manager
|
||||
: this.revisionRepo.manager;
|
||||
await manager.save(revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CorrespondenceService } from './correspondence.service.js';
|
||||
import { CorrespondenceController } from './correspondence.controller.js';
|
||||
import { Correspondence } from './entities/correspondence.entity.js';
|
||||
import { CorrespondenceService } from './correspondence.service.js';
|
||||
import { CorrespondenceRevision } from './entities/correspondence-revision.entity.js';
|
||||
import { CorrespondenceType } from './entities/correspondence-type.entity.js';
|
||||
import { Correspondence } from './entities/correspondence.entity.js';
|
||||
// Import Entities ใหม่
|
||||
import { RoutingTemplate } from './entities/routing-template.entity.js';
|
||||
import { RoutingTemplateStep } from './entities/routing-template-step.entity.js';
|
||||
import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js';
|
||||
import { RoutingTemplateStep } from './entities/routing-template-step.entity.js';
|
||||
import { RoutingTemplate } from './entities/routing-template.entity.js';
|
||||
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity.js';
|
||||
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module.js'; // ต้องใช้ตอน Create
|
||||
import { JsonSchemaModule } from '../json-schema/json-schema.module.js'; // ต้องใช้ Validate Details
|
||||
import { SearchModule } from '../search/search.module'; // ✅ 1. เพิ่ม Import SearchModule
|
||||
import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module.js'; // <--- ✅ เพิ่มบรรทัดนี้ครับ
|
||||
import { CorrespondenceReference } from './entities/correspondence-reference.entity.js';
|
||||
import { SearchModule } from '../search/search.module'; // ✅ 1. เพิ่ม Import SearchModule
|
||||
import { CorrespondenceStatus } from './entities/correspondence-status.entity.js';
|
||||
// Controllers & Services
|
||||
import { CorrespondenceWorkflowService } from './correspondence-workflow.service'; // Register Service นี้
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -37,7 +39,7 @@ import { SearchModule } from '../search/search.module'; // ✅ 1. เพิ่
|
||||
SearchModule, // ✅ 2. ใส่ SearchModule ที่นี่
|
||||
],
|
||||
controllers: [CorrespondenceController],
|
||||
providers: [CorrespondenceService],
|
||||
providers: [CorrespondenceService, CorrespondenceWorkflowService],
|
||||
exports: [CorrespondenceService],
|
||||
})
|
||||
export class CorrespondenceModule {}
|
||||
|
||||
@@ -15,14 +15,10 @@ import { VirtualColumnService } from './services/virtual-column.service';
|
||||
import { CryptoService } from '../../common/services/crypto.service';
|
||||
|
||||
// Import Module อื่นๆ ที่จำเป็นสำหรับ Guard (ถ้า Guards อยู่ใน Common อาจจะไม่ต้อง import ที่นี่โดยตรง)
|
||||
// import { UserModule } from '../user/user.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([JsonSchema]),
|
||||
ConfigModule,
|
||||
// UserModule,
|
||||
],
|
||||
imports: [TypeOrmModule.forFeature([JsonSchema]), ConfigModule, UserModule],
|
||||
controllers: [JsonSchemaController],
|
||||
providers: [
|
||||
JsonSchemaService,
|
||||
|
||||
@@ -1,172 +1,406 @@
|
||||
// File: src/modules/json-schema/json-schema.controller.ts
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
// File: src/modules/json-schema/json-schema.service.ts
|
||||
// บันทึกการแก้ไข: Fix TS2345 (undefined check)
|
||||
|
||||
import { JsonSchemaService } from './json-schema.service';
|
||||
import { SchemaMigrationService } from './services/schema-migration.service';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import Ajv, { ValidateFunction } from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CreateJsonSchemaDto } from './dto/create-json-schema.dto';
|
||||
import { MigrateDataDto } from './dto/migrate-data.dto';
|
||||
import { SearchJsonSchemaDto } from './dto/search-json-schema.dto';
|
||||
import { UpdateJsonSchemaDto } from './dto/update-json-schema.dto';
|
||||
import { JsonSchema } from './entities/json-schema.entity';
|
||||
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { RbacGuard } from '../../common/guards/rbac.guard';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
// Services ย่อยที่แยกตามหน้าที่ (Single Responsibility)
|
||||
import {
|
||||
JsonSecurityService,
|
||||
SecurityContext,
|
||||
} from './services/json-security.service';
|
||||
import { UiSchemaService } from './services/ui-schema.service';
|
||||
import { VirtualColumnService } from './services/virtual-column.service';
|
||||
|
||||
import {
|
||||
ValidationErrorDetail,
|
||||
ValidationOptions,
|
||||
ValidationResult,
|
||||
} from './interfaces/validation-result.interface';
|
||||
|
||||
@Injectable()
|
||||
export class JsonSchemaService implements OnModuleInit {
|
||||
private ajv: Ajv;
|
||||
private validators = new Map<string, ValidateFunction>(); // Cache สำหรับเก็บ Validator ที่ Compile แล้ว
|
||||
private readonly logger = new Logger(JsonSchemaService.name);
|
||||
|
||||
// ค่า Default สำหรับการตรวจสอบข้อมูล
|
||||
private readonly defaultOptions: ValidationOptions = {
|
||||
removeAdditional: true, // ลบฟิลด์เกิน
|
||||
coerceTypes: true, // แปลงชนิดข้อมูลอัตโนมัติ (เช่น "123" -> 123)
|
||||
useDefaults: true, // ใส่ค่า Default ถ้าไม่มีข้อมูล
|
||||
};
|
||||
|
||||
@ApiTags('JSON Schemas Management')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Controller('json-schemas')
|
||||
export class JsonSchemaController {
|
||||
constructor(
|
||||
private readonly jsonSchemaService: JsonSchemaService,
|
||||
private readonly migrationService: SchemaMigrationService,
|
||||
) {}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Schema Management (CRUD)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: 'Create a new schema or new version of existing schema',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'The schema has been successfully created.',
|
||||
})
|
||||
@RequirePermission('system.manage_all') // Admin Only
|
||||
create(@Body() createDto: CreateJsonSchemaDto) {
|
||||
return this.jsonSchemaService.create(createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all schemas with pagination and filtering' })
|
||||
@RequirePermission('document.view') // Viewer+ can see schemas
|
||||
findAll(@Query() searchDto: SearchJsonSchemaDto) {
|
||||
return this.jsonSchemaService.findAll(searchDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a specific schema version by ID' })
|
||||
@RequirePermission('document.view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.jsonSchemaService.findOne(id);
|
||||
}
|
||||
|
||||
@Get('latest/:code')
|
||||
@ApiOperation({
|
||||
summary: 'Get the latest active version of a schema by code',
|
||||
})
|
||||
@ApiParam({ name: 'code', description: 'Schema Code (e.g., RFA_DWG)' })
|
||||
@RequirePermission('document.view')
|
||||
findLatest(@Param('code') code: string) {
|
||||
return this.jsonSchemaService.findLatestByCode(code);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Update a specific schema (Not recommended for active schemas)',
|
||||
})
|
||||
@RequirePermission('system.manage_all')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() updateDto: UpdateJsonSchemaDto,
|
||||
@InjectRepository(JsonSchema)
|
||||
private readonly jsonSchemaRepository: Repository<JsonSchema>,
|
||||
private readonly virtualColumnService: VirtualColumnService,
|
||||
private readonly uiSchemaService: UiSchemaService,
|
||||
private readonly jsonSecurityService: JsonSecurityService,
|
||||
) {
|
||||
return this.jsonSchemaService.update(id, updateDto);
|
||||
// กำหนดค่าเริ่มต้นให้กับ AJV Validation Engine
|
||||
this.ajv = new Ajv({
|
||||
allErrors: true, // แสดง Error ทั้งหมด ไม่หยุดแค่จุดแรก
|
||||
strict: false, // ไม่เคร่งครัดเกินไป (ยอมรับ Keyword แปลกๆ เช่น ui:widget)
|
||||
coerceTypes: true,
|
||||
useDefaults: true,
|
||||
removeAdditional: true,
|
||||
});
|
||||
addFormats(this.ajv); // เพิ่ม Format มาตรฐาน (email, date, uri ฯลฯ)
|
||||
this.registerCustomValidators(); // ลงทะเบียน Validator เฉพาะของโปรเจกต์
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a schema version (Hard Delete)' })
|
||||
@RequirePermission('system.manage_all')
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.jsonSchemaService.remove(id);
|
||||
async onModuleInit() {
|
||||
// สามารถโหลด Schema ที่ Active ทั้งหมดมา Cache ไว้ล่วงหน้าได้ที่นี่ เพื่อความเร็วในการตอบสนองครั้งแรก
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Validation & Security
|
||||
// ----------------------------------------------------------------------
|
||||
/**
|
||||
* ลงทะเบียน Custom Validators เฉพาะสำหรับ LCBP3
|
||||
*/
|
||||
private registerCustomValidators() {
|
||||
// 1. ตรวจสอบรูปแบบเลขที่เอกสาร (เช่น TEAM-RFA-STR-0001)
|
||||
this.ajv.addFormat('document-number', {
|
||||
type: 'string',
|
||||
validate: (value: string) => {
|
||||
// Regex อย่างง่าย: กลุ่มตัวอักษรขีดคั่นด้วย -
|
||||
return /^[A-Z0-9]{2,10}-[A-Z]{2,5}(-[A-Z0-9]{2,5})?-\d{4}-\d{3,5}$/.test(
|
||||
value,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@Post('validate/:code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Validate data against the latest schema version' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Validation result including errors and sanitized data',
|
||||
})
|
||||
@RequirePermission('document.view')
|
||||
async validate(@Param('code') code: string, @Body() data: any) {
|
||||
// Note: Validation API นี้ใช้สำหรับ Test หรือ Pre-check เท่านั้น
|
||||
// การ Save จริงจะเรียกผ่าน Service ภายใน
|
||||
return this.jsonSchemaService.validateData(code, data);
|
||||
// 2. Keyword สำหรับระบุ Role ที่จำเป็น (ใช้ร่วมกับ Security Service)
|
||||
this.ajv.addKeyword({
|
||||
keyword: 'requiredRole',
|
||||
type: 'string',
|
||||
metaSchema: { type: 'string' },
|
||||
validate: (schema: string, data: any) => true, // ผ่านเสมอในขั้น AJV (Security Service จะจัดการเอง)
|
||||
});
|
||||
}
|
||||
|
||||
@Post('read/:code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Process read data (Decrypt & Filter) based on user roles',
|
||||
})
|
||||
@RequirePermission('document.view')
|
||||
/**
|
||||
* สร้าง Schema ใหม่ พร้อมจัดการ Version, UI Schema และ Virtual Columns
|
||||
*/
|
||||
async create(createDto: CreateJsonSchemaDto): Promise<JsonSchema> {
|
||||
// 1. ตรวจสอบความถูกต้องของ JSON Schema Definition (AJV Syntax)
|
||||
try {
|
||||
this.ajv.compile(createDto.schemaDefinition);
|
||||
} catch (error: any) {
|
||||
throw new BadRequestException(
|
||||
`Invalid JSON Schema format: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. จัดการ UI Schema
|
||||
if (createDto.uiSchema) {
|
||||
// ถ้าส่งมา ให้ตรวจสอบความถูกต้องเทียบกับ Data Schema
|
||||
this.uiSchemaService.validateUiSchema(
|
||||
createDto.uiSchema as any,
|
||||
createDto.schemaDefinition,
|
||||
);
|
||||
} else {
|
||||
// ถ้าไม่ส่งมา ให้สร้าง UI Schema พื้นฐานให้อัตโนมัติ
|
||||
createDto.uiSchema = this.uiSchemaService.generateDefaultUiSchema(
|
||||
createDto.schemaDefinition,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. จัดการ Versioning อัตโนมัติ (Auto-increment)
|
||||
const latestSchema = await this.jsonSchemaRepository.findOne({
|
||||
where: { schemaCode: createDto.schemaCode },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
|
||||
let newVersion = 1;
|
||||
if (latestSchema) {
|
||||
// ถ้าผู้ใช้ไม่ระบุ Version หรือระบุมาน้อยกว่าล่าสุด ให้ +1
|
||||
if (!createDto.version || createDto.version <= latestSchema.version) {
|
||||
newVersion = latestSchema.version + 1;
|
||||
} else {
|
||||
newVersion = createDto.version;
|
||||
}
|
||||
} else if (createDto.version) {
|
||||
newVersion = createDto.version;
|
||||
}
|
||||
|
||||
// 4. บันทึกลงฐานข้อมูล
|
||||
const newSchema = this.jsonSchemaRepository.create({
|
||||
...createDto,
|
||||
version: newVersion,
|
||||
});
|
||||
|
||||
const savedSchema = await this.jsonSchemaRepository.save(newSchema);
|
||||
|
||||
// ล้าง Cache เพื่อให้โหลดตัวใหม่ในครั้งถัดไป
|
||||
this.validators.delete(savedSchema.schemaCode);
|
||||
|
||||
this.logger.log(
|
||||
`Schema '${savedSchema.schemaCode}' created (v${savedSchema.version})`,
|
||||
);
|
||||
|
||||
// 5. สร้าง/อัปเดต Virtual Columns บน Database จริง (Performance Optimization)
|
||||
// Fix TS2345: Add empty array fallback
|
||||
if (savedSchema.virtualColumns && savedSchema.virtualColumns.length > 0) {
|
||||
await this.virtualColumnService.setupVirtualColumns(
|
||||
savedSchema.tableName,
|
||||
savedSchema.virtualColumns || [],
|
||||
);
|
||||
}
|
||||
|
||||
return savedSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหา Schema ทั้งหมด (Pagination & Filter)
|
||||
*/
|
||||
async findAll(searchDto: SearchJsonSchemaDto) {
|
||||
const { search, isActive, page = 1, limit = 20 } = searchDto;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = this.jsonSchemaRepository.createQueryBuilder('schema');
|
||||
|
||||
if (search) {
|
||||
query.andWhere('schema.schemaCode LIKE :search', {
|
||||
search: `%${search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
query.andWhere('schema.isActive = :isActive', { isActive });
|
||||
}
|
||||
|
||||
// เรียงตาม Code ก่อน แล้วตามด้วย Version ล่าสุด
|
||||
query.orderBy('schema.schemaCode', 'ASC');
|
||||
query.addOrderBy('schema.version', 'DESC');
|
||||
|
||||
const [items, total] = await query.skip(skip).take(limit).getManyAndCount();
|
||||
|
||||
return {
|
||||
data: items,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงข้อมูล Schema ตาม ID
|
||||
*/
|
||||
async findOne(id: number): Promise<JsonSchema> {
|
||||
const schema = await this.jsonSchemaRepository.findOne({ where: { id } });
|
||||
if (!schema) {
|
||||
throw new NotFoundException(`JsonSchema with ID ${id} not found`);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงข้อมูล Schema ตาม Code และ Version (สำหรับ Migration)
|
||||
*/
|
||||
async findOneByCodeAndVersion(
|
||||
code: string,
|
||||
version: number,
|
||||
): Promise<JsonSchema> {
|
||||
const schema = await this.jsonSchemaRepository.findOne({
|
||||
where: { schemaCode: code, version },
|
||||
});
|
||||
|
||||
if (!schema) {
|
||||
throw new NotFoundException(
|
||||
`JsonSchema '${code}' version ${version} not found`,
|
||||
);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Schema เวอร์ชันล่าสุดที่ Active (สำหรับใช้งานทั่วไป)
|
||||
*/
|
||||
async findLatestByCode(code: string): Promise<JsonSchema> {
|
||||
const schema = await this.jsonSchemaRepository.findOne({
|
||||
where: { schemaCode: code, isActive: true },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
|
||||
if (!schema) {
|
||||
throw new NotFoundException(
|
||||
`Active JsonSchema with code '${code}' not found`,
|
||||
);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORE FUNCTION] ตรวจสอบข้อมูล (Validate), ทำความสะอาด (Sanitize) และเข้ารหัส (Encrypt)
|
||||
* ใช้สำหรับ "ขาเข้า" (Write) ก่อนบันทึกลง Database
|
||||
*/
|
||||
async validateData(
|
||||
schemaCode: string,
|
||||
data: any,
|
||||
options: ValidationOptions = {},
|
||||
): Promise<ValidationResult> {
|
||||
// 1. ดึงและ Compile Validator
|
||||
const validate = await this.getValidator(schemaCode);
|
||||
const schema = await this.findLatestByCode(schemaCode); // ดึง Full Schema เพื่อใช้ Config อื่นๆ
|
||||
|
||||
// 2. สำเนาข้อมูลเพื่อป้องกัน Side Effect และเตรียมสำหรับ AJV Mutation (Sanitization)
|
||||
const dataToValidate = JSON.parse(JSON.stringify(data));
|
||||
|
||||
// 3. เริ่มการตรวจสอบ (AJV จะทำการ Coerce Type และ Remove Additional Properties ให้ด้วย)
|
||||
const valid = validate(dataToValidate);
|
||||
|
||||
// 4. จัดการกรณีข้อมูลไม่ถูกต้อง
|
||||
if (!valid) {
|
||||
const errors: ValidationErrorDetail[] = (validate.errors || []).map(
|
||||
(err) => ({
|
||||
field: err.instancePath || 'root',
|
||||
message: err.message || 'Validation error',
|
||||
value: err.params,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
errors,
|
||||
sanitizedData: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. เข้ารหัสข้อมูล (Encryption) สำหรับ Field ที่มีความลับ (x-encrypt: true)
|
||||
const secureData = this.jsonSecurityService.encryptFields(
|
||||
dataToValidate,
|
||||
schema.schemaDefinition,
|
||||
);
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
sanitizedData: secureData, // ข้อมูลนี้สะอาดและปลอดภัย พร้อมบันทึก
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORE FUNCTION] อ่านข้อมูล, ถอดรหัส (Decrypt) และกรองตามสิทธิ์ (Filter)
|
||||
* ใช้สำหรับ "ขาออก" (Read) ก่อนส่งให้ Frontend
|
||||
*/
|
||||
async processReadData(
|
||||
@Param('code') code: string,
|
||||
@Body() data: any,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
// แปลง User Entity เป็น Security Context
|
||||
// แก้ไข TS2339 & TS7006: Type Casting เพื่อให้เข้าถึง roles ได้โดยไม่ error
|
||||
// เนื่องจาก User Entity ปกติไม่มี property roles (แต่อาจถูก Inject มาตอน Runtime หรือผ่าน Assignments)
|
||||
const userWithRoles = user as any;
|
||||
const userRoles = userWithRoles.roles
|
||||
? userWithRoles.roles.map((r: any) => r.roleName)
|
||||
: [];
|
||||
schemaCode: string,
|
||||
data: any,
|
||||
userContext: SecurityContext,
|
||||
): Promise<any> {
|
||||
if (!data) return data;
|
||||
|
||||
return this.jsonSchemaService.processReadData(code, data, { userRoles });
|
||||
}
|
||||
// ดึง Schema เพื่อดู Config การถอดรหัสและการมองเห็น
|
||||
const schema = await this.findLatestByCode(schemaCode);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Data Migration
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Post('migrate/:table/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Migrate specific entity data to target schema version',
|
||||
})
|
||||
@ApiParam({ name: 'table', description: 'Table Name (e.g. rfa_revisions)' })
|
||||
@ApiParam({ name: 'id', description: 'Entity ID' })
|
||||
@RequirePermission('system.manage_all') // Dangerous Op -> Admin Only
|
||||
async migrateData(
|
||||
@Param('table') tableName: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: MigrateDataDto,
|
||||
) {
|
||||
return this.migrationService.migrateData(
|
||||
tableName,
|
||||
id,
|
||||
dto.targetSchemaCode,
|
||||
dto.targetVersion,
|
||||
return this.jsonSecurityService.decryptAndFilterFields(
|
||||
data,
|
||||
schema.schemaDefinition,
|
||||
userContext,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: ดึงและ Cache AJV Validator Function เพื่อประสิทธิภาพ
|
||||
*/
|
||||
private async getValidator(schemaCode: string): Promise<ValidateFunction> {
|
||||
let validate = this.validators.get(schemaCode);
|
||||
|
||||
if (!validate) {
|
||||
const schema = await this.findLatestByCode(schemaCode);
|
||||
try {
|
||||
validate = this.ajv.compile(schema.schemaDefinition);
|
||||
this.validators.set(schemaCode, validate);
|
||||
} catch (error: any) {
|
||||
throw new BadRequestException(
|
||||
`Invalid Schema Definition for '${schemaCode}': ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return validate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper เก่าสำหรับ Backward Compatibility (ถ้ามีโค้ดเก่าเรียกใช้)
|
||||
*/
|
||||
async validate(schemaCode: string, data: any): Promise<boolean> {
|
||||
const result = await this.validateData(schemaCode, data);
|
||||
if (!result.isValid) {
|
||||
const errorMsg = result.errors
|
||||
.map((e) => `${e.field}: ${e.message}`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(`JSON Validation Failed: ${errorMsg}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดตข้อมูล Schema และจัดการผลกระทบ (Virtual Columns / UI Schema)
|
||||
*/
|
||||
async update(
|
||||
id: number,
|
||||
updateDto: UpdateJsonSchemaDto,
|
||||
): Promise<JsonSchema> {
|
||||
const schema = await this.findOne(id);
|
||||
|
||||
// ตรวจสอบ JSON Schema
|
||||
if (updateDto.schemaDefinition) {
|
||||
try {
|
||||
this.ajv.compile(updateDto.schemaDefinition);
|
||||
} catch (error: any) {
|
||||
throw new BadRequestException(
|
||||
`Invalid JSON Schema format: ${error.message}`,
|
||||
);
|
||||
}
|
||||
this.validators.delete(schema.schemaCode); // เคลียร์ Cache เก่า
|
||||
}
|
||||
|
||||
// ตรวจสอบ UI Schema
|
||||
if (updateDto.uiSchema) {
|
||||
this.uiSchemaService.validateUiSchema(
|
||||
updateDto.uiSchema as any,
|
||||
updateDto.schemaDefinition || schema.schemaDefinition,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedSchema = this.jsonSchemaRepository.merge(schema, updateDto);
|
||||
const savedSchema = await this.jsonSchemaRepository.save(updatedSchema);
|
||||
|
||||
// อัปเดต Virtual Columns ใน Database ถ้ามีการเปลี่ยนแปลง Config
|
||||
// Fix TS2345: Add empty array fallback
|
||||
if (updateDto.virtualColumns && updatedSchema.virtualColumns) {
|
||||
await this.virtualColumnService.setupVirtualColumns(
|
||||
savedSchema.tableName,
|
||||
savedSchema.virtualColumns || [],
|
||||
);
|
||||
}
|
||||
|
||||
return savedSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบ Schema (Hard Delete)
|
||||
*/
|
||||
async remove(id: number): Promise<void> {
|
||||
const schema = await this.findOne(id);
|
||||
this.validators.delete(schema.schemaCode);
|
||||
await this.jsonSchemaRepository.remove(schema);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,66 @@
|
||||
// File: src/modules/rfa/dto/create-rfa-revision.dto.ts
|
||||
// File: src/modules/rfa/dto/create-rfa.dto.ts
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateRfaRevisionDto {
|
||||
export class CreateRfaDto {
|
||||
@ApiProperty({ description: 'ID ของโครงการ', example: 1 })
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
projectId!: number;
|
||||
|
||||
@ApiProperty({ description: 'ID ของประเภท RFA', example: 1 })
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
rfaTypeId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID ของสาขางาน (Discipline) ตาม Req 6B',
|
||||
example: 1,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional() // Optional ไว้ก่อนเผื่อบางโครงการไม่บังคับ
|
||||
disciplineId?: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'หัวข้อเอกสาร',
|
||||
example: 'Submission of Shop Drawing for Building A',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
rfaStatusCodeId!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
rfaApproveCodeId?: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
documentDate?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
issuedDate?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
receivedDate?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
approvedDate?: string;
|
||||
|
||||
@ApiProperty({ description: 'รายละเอียดเพิ่มเติม', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'วันที่ในเอกสาร', required: false })
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
documentDate?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ข้อมูล Dynamic Details (JSON)',
|
||||
required: false,
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
details?: Record<string, any>;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'รายการ Shop Drawing Revisions ที่แนบมาด้วย',
|
||||
required: false,
|
||||
type: [Number],
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
shopDrawingRevisionIds?: number[]; // IDs of linked Shop Drawings
|
||||
shopDrawingRevisionIds?: number[];
|
||||
}
|
||||
|
||||
13
backend/src/modules/rfa/dto/submit-rfa.dto.ts
Normal file
13
backend/src/modules/rfa/dto/submit-rfa.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// File: src/modules/rfa/dto/submit-rfa.dto.ts
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class SubmitRfaDto {
|
||||
@ApiProperty({
|
||||
description: 'ID ของ Routing Template ที่จะใช้เดินเรื่อง',
|
||||
example: 1,
|
||||
})
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
templateId!: number;
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
// File: src/modules/rfa/entities/rfa-revision.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Rfa } from './rfa.entity';
|
||||
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
|
||||
import { RfaStatusCode } from './rfa-status-code.entity';
|
||||
import { RfaApproveCode } from './rfa-approve-code.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { RfaApproveCode } from './rfa-approve-code.entity';
|
||||
import { RfaItem } from './rfa-item.entity';
|
||||
import { RfaStatusCode } from './rfa-status-code.entity';
|
||||
import { RfaWorkflow } from './rfa-workflow.entity';
|
||||
import { Rfa } from './rfa.entity';
|
||||
|
||||
@Entity('rfa_revisions')
|
||||
@Unique(['rfaId', 'revisionNumber'])
|
||||
@@ -65,11 +63,16 @@ export class RfaRevision {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
// ✅ [New] เพิ่ม field details สำหรับเก็บข้อมูล Dynamic ของ RFA (เช่น Method Statement Details)
|
||||
// --- JSON & Schema Section ---
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
details?: any;
|
||||
|
||||
// ✅ [New] Virtual Column: ดึงจำนวนแบบที่แนบ (drawingCount) จาก JSON
|
||||
// ✅ [New] จำเป็นสำหรับ Data Migration (T2.5.5)
|
||||
@Column({ name: 'schema_version', default: 1 })
|
||||
schemaVersion!: number;
|
||||
|
||||
// ✅ Virtual Column
|
||||
@Column({
|
||||
name: 'v_ref_drawing_count',
|
||||
type: 'int',
|
||||
@@ -79,6 +82,8 @@ export class RfaRevision {
|
||||
})
|
||||
vRefDrawingCount?: number;
|
||||
|
||||
// --- Timestamp ---
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -110,11 +115,9 @@ export class RfaRevision {
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
creator?: User;
|
||||
|
||||
// Items (Shop Drawings inside this RFA)
|
||||
@OneToMany(() => RfaItem, (item) => item.rfaRevision, { cascade: true })
|
||||
items!: RfaItem[];
|
||||
|
||||
// Workflows
|
||||
@OneToMany(() => RfaWorkflow, (workflow) => workflow.rfaRevision, {
|
||||
cascade: true,
|
||||
})
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// File: src/modules/rfa/entities/rfa-workflow.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { RfaRevision } from './rfa-revision.entity';
|
||||
import { Organization } from '../../project/entities/organization.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { RfaRevision } from './rfa-revision.entity';
|
||||
import { RfaActionType } from './rfa-workflow-template-step.entity'; // ✅ Import Enum
|
||||
|
||||
@Entity('rfa_workflows')
|
||||
export class RfaWorkflow {
|
||||
@@ -31,10 +33,10 @@ export class RfaWorkflow {
|
||||
@Column({
|
||||
name: 'action_type',
|
||||
type: 'enum',
|
||||
enum: ['REVIEW', 'APPROVE', 'ACKNOWLEDGE'],
|
||||
enum: RfaActionType, // ✅ Use Shared Enum
|
||||
nullable: true,
|
||||
})
|
||||
actionType?: string;
|
||||
actionType?: RfaActionType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
@@ -50,7 +52,7 @@ export class RfaWorkflow {
|
||||
completedAt?: Date;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
stateContext?: Record<string, any>; // เก็บ Snapshot ข้อมูล ณ ขณะนั้น
|
||||
stateContext?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
@@ -59,7 +61,7 @@ export class RfaWorkflow {
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => RfaRevision, (rev) => rev.workflows, { onDelete: 'CASCADE' }) // ต้องไปเพิ่ม Property workflows ใน RfaRevision ด้วย
|
||||
@ManyToOne(() => RfaRevision, (rev) => rev.workflows, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'rfa_revision_id' })
|
||||
rfaRevision!: RfaRevision;
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
ManyToOne,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { RfaType } from './rfa-type.entity';
|
||||
import { Discipline } from '../../master/entities/discipline.entity'; // Import ใหม่
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { RfaRevision } from './rfa-revision.entity';
|
||||
import { RfaType } from './rfa-type.entity';
|
||||
|
||||
@Entity('rfas')
|
||||
export class Rfa {
|
||||
@@ -34,6 +35,11 @@ export class Rfa {
|
||||
@JoinColumn({ name: 'rfa_type_id' })
|
||||
rfaType!: RfaType;
|
||||
|
||||
// ✅ [NEW] Relation
|
||||
@ManyToOne(() => Discipline)
|
||||
@JoinColumn({ name: 'discipline_id' })
|
||||
discipline?: Discipline;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'created_by' })
|
||||
creator?: User;
|
||||
|
||||
193
backend/src/modules/rfa/rfa-workflow.service.ts
Normal file
193
backend/src/modules/rfa/rfa-workflow.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// File: src/modules/rfa/rfa-workflow.service.ts
|
||||
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
// Modules
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
|
||||
// Entities
|
||||
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
|
||||
import { RfaRevision } from './entities/rfa-revision.entity';
|
||||
import { RfaStatusCode } from './entities/rfa-status-code.entity';
|
||||
import { Rfa } from './entities/rfa.entity';
|
||||
|
||||
// DTOs
|
||||
import { WorkflowTransitionDto } from '../workflow-engine/dto/workflow-transition.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RfaWorkflowService {
|
||||
private readonly logger = new Logger(RfaWorkflowService.name);
|
||||
private readonly WORKFLOW_CODE = 'RFA_FLOW_V1'; // ควรกำหนดใน Config หรือ Enum
|
||||
|
||||
constructor(
|
||||
private readonly workflowEngine: WorkflowEngineService,
|
||||
@InjectRepository(Rfa)
|
||||
private readonly rfaRepo: Repository<Rfa>,
|
||||
@InjectRepository(RfaRevision)
|
||||
private readonly revisionRepo: Repository<RfaRevision>,
|
||||
@InjectRepository(RfaStatusCode)
|
||||
private readonly statusRepo: Repository<RfaStatusCode>,
|
||||
@InjectRepository(RfaApproveCode)
|
||||
private readonly approveCodeRepo: Repository<RfaApproveCode>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* เริ่มต้น Workflow สำหรับเอกสาร RFA (เมื่อกด Submit)
|
||||
*/
|
||||
async submitWorkflow(rfaId: number, userId: number, note?: string) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 1. ดึงข้อมูล Revision ปัจจุบัน
|
||||
const revision = await this.revisionRepo.findOne({
|
||||
where: { id: rfaId, isCurrent: true },
|
||||
relations: ['rfa'],
|
||||
});
|
||||
|
||||
if (!revision) {
|
||||
throw new NotFoundException(
|
||||
`Current Revision for RFA ID ${rfaId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. สร้าง Context (ข้อมูลประกอบการตัดสินใจ)
|
||||
const context = {
|
||||
rfaType: revision.rfa.rfaTypeId,
|
||||
discipline: revision.rfa.discipline,
|
||||
ownerId: userId,
|
||||
// อาจเพิ่มเงื่อนไขอื่นๆ เช่น จำนวนวัน, ความเร่งด่วน
|
||||
};
|
||||
|
||||
// 3. สร้าง Workflow Instance
|
||||
// Entity Type = 'rfa_revision'
|
||||
const instance = await this.workflowEngine.createInstance(
|
||||
this.WORKFLOW_CODE,
|
||||
'rfa_revision',
|
||||
revision.id.toString(),
|
||||
context,
|
||||
);
|
||||
|
||||
// 4. Auto Transition: SUBMIT
|
||||
const transitionResult = await this.workflowEngine.processTransition(
|
||||
instance.id,
|
||||
'SUBMIT',
|
||||
userId,
|
||||
note || 'RFA Submitted',
|
||||
{},
|
||||
);
|
||||
|
||||
// 5. Sync สถานะกลับตาราง RFA Revision
|
||||
await this.syncStatus(
|
||||
revision,
|
||||
transitionResult.nextState,
|
||||
undefined,
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
this.logger.log(
|
||||
`Started workflow for RFA #${rfaId} (Instance: ${instance.id})`,
|
||||
);
|
||||
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
currentState: transitionResult.nextState,
|
||||
};
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(`Failed to submit RFA workflow: ${error}`);
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ดำเนินการอนุมัติ/ตรวจสอบ RFA
|
||||
*/
|
||||
async processAction(
|
||||
instanceId: string,
|
||||
userId: number,
|
||||
dto: WorkflowTransitionDto,
|
||||
) {
|
||||
// 1. ส่งคำสั่งให้ Engine ประมวลผล
|
||||
const result = await this.workflowEngine.processTransition(
|
||||
instanceId,
|
||||
dto.action,
|
||||
userId,
|
||||
dto.comment,
|
||||
dto.payload,
|
||||
);
|
||||
|
||||
// 2. Sync สถานะกลับตารางเดิม
|
||||
const instance = await this.workflowEngine.getInstanceById(instanceId);
|
||||
if (instance && instance.entityType === 'rfa_revision') {
|
||||
const revision = await this.revisionRepo.findOne({
|
||||
where: { id: parseInt(instance.entityId) },
|
||||
});
|
||||
if (revision) {
|
||||
// เช็คว่า Action นี้มีการระบุ Approve Code มาใน Payload หรือไม่ (เช่น '1A', '3R')
|
||||
const approveCodeStr = dto.payload?.approveCode;
|
||||
await this.syncStatus(revision, result.nextState, approveCodeStr);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Map Workflow State -> RFA Status & Approve Code
|
||||
*/
|
||||
private async syncStatus(
|
||||
revision: RfaRevision,
|
||||
workflowState: string,
|
||||
approveCodeStr?: string, // เช่น '1A', '1C'
|
||||
queryRunner?: any,
|
||||
) {
|
||||
// 1. Map Workflow State -> RFA Status Code (DFT, FAP, FCO...)
|
||||
const statusMap: Record<string, string> = {
|
||||
DRAFT: 'DFT',
|
||||
IN_REVIEW_CSC: 'FRE', // For Review (CSC)
|
||||
IN_REVIEW_OWNER: 'FAP', // For Approve (Owner)
|
||||
APPROVED: 'FCO', // For Construction (ตัวอย่าง)
|
||||
REJECTED: 'CC', // Canceled/Rejected
|
||||
REVISE: 'DFT', // กลับไปแก้ (Draft)
|
||||
};
|
||||
|
||||
const targetStatusCode = statusMap[workflowState] || 'DFT';
|
||||
const status = await this.statusRepo.findOne({
|
||||
where: { statusCode: targetStatusCode },
|
||||
});
|
||||
|
||||
if (status) {
|
||||
revision.rfaStatusCodeId = status.id;
|
||||
}
|
||||
|
||||
// 2. Map Approve Code (ถ้ามี)
|
||||
if (approveCodeStr) {
|
||||
const approveCode = await this.approveCodeRepo.findOne({
|
||||
where: { approveCode: approveCodeStr },
|
||||
});
|
||||
if (approveCode) {
|
||||
revision.rfaApproveCodeId = approveCode.id;
|
||||
revision.approvedDate = new Date(); // บันทึกวันที่อนุมัติ
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Save
|
||||
const manager = queryRunner
|
||||
? queryRunner.manager
|
||||
: this.revisionRepo.manager;
|
||||
await manager.save(revision);
|
||||
|
||||
this.logger.log(
|
||||
`Synced RFA Status Revision ${revision.id}: State=${workflowState} -> Status=${targetStatusCode}, AppCode=${approveCodeStr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
// File: src/modules/rfa/rfa.controller.ts
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { RfaService } from './rfa.service';
|
||||
import { CreateRfaDto } from './dto/create-rfa.dto';
|
||||
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; // Reuse DTO
|
||||
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { CreateRfaDto } from './dto/create-rfa.dto';
|
||||
import { SubmitRfaDto } from './dto/submit-rfa.dto'; // ✅ Import DTO ใหม่
|
||||
import { RfaService } from './rfa.service';
|
||||
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
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';
|
||||
import { Audit } from '../../common/decorators/audit.decorator'; // Import
|
||||
|
||||
@ApiTags('RFA (Request for Approval)')
|
||||
@ApiBearerAuth()
|
||||
@@ -29,26 +31,28 @@ export class RfaController {
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create new RFA (Draft)' })
|
||||
@RequirePermission('rfa.create') // สิทธิ์ ID 37
|
||||
@Audit('rfa.create', 'rfa') // ✅ แปะตรงนี้
|
||||
@RequirePermission('rfa.create')
|
||||
@Audit('rfa.create', 'rfa')
|
||||
create(@Body() createDto: CreateRfaDto, @CurrentUser() user: User) {
|
||||
return this.rfaService.create(createDto, user);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: 'Submit RFA to Workflow' })
|
||||
@RequirePermission('rfa.create') // ผู้สร้างมีสิทธิ์ส่ง
|
||||
@RequirePermission('rfa.create')
|
||||
@Audit('rfa.submit', 'rfa')
|
||||
submit(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body('templateId', ParseIntPipe) templateId: number, // รับ Template ID
|
||||
@Body() submitDto: SubmitRfaDto, // ✅ ใช้ DTO
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
return this.rfaService.submit(id, templateId, user);
|
||||
return this.rfaService.submit(id, submitDto.templateId, user);
|
||||
}
|
||||
|
||||
@Post(':id/action')
|
||||
@ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' })
|
||||
@RequirePermission('workflow.action_review') // สิทธิ์ในการ Approve/Review
|
||||
@RequirePermission('workflow.action_review')
|
||||
@Audit('rfa.action', 'rfa')
|
||||
processAction(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() actionDto: WorkflowActionDto,
|
||||
|
||||
@@ -3,35 +3,34 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
// Entities
|
||||
import { Rfa } from './entities/rfa.entity';
|
||||
import { RfaRevision } from './entities/rfa-revision.entity';
|
||||
import { RfaItem } from './entities/rfa-item.entity';
|
||||
import { RfaType } from './entities/rfa-type.entity';
|
||||
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 { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
|
||||
// หมายเหตุ: ตรวจสอบชื่อไฟล์ Entity ให้ตรงกับที่มีจริง (บางทีอาจชื่อ RoutingTemplate)
|
||||
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
||||
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
|
||||
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 { RfaService } from './rfa.service';
|
||||
import { RfaWorkflowService } from './rfa-workflow.service'; // Register Service
|
||||
import { RfaController } from './rfa.controller';
|
||||
import { RfaService } from './rfa.service';
|
||||
|
||||
// External Modules
|
||||
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; // ✅ Import
|
||||
import { NotificationModule } from '../notification/notification.module'; // ✅ เพิ่ม NotificationModule
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 1. Register Entities (เฉพาะ Entity เท่านั้น ห้ามใส่ Module)
|
||||
TypeOrmModule.forFeature([
|
||||
Rfa,
|
||||
RfaRevision,
|
||||
@@ -47,15 +46,13 @@ import { NotificationModule } from '../notification/notification.module'; // ✅
|
||||
CorrespondenceRouting,
|
||||
RoutingTemplate,
|
||||
]),
|
||||
|
||||
// 2. Import External Modules (Services ที่ Inject เข้ามา)
|
||||
DocumentNumberingModule,
|
||||
UserModule,
|
||||
SearchModule,
|
||||
WorkflowEngineModule, // ✅ ย้ายมาใส่ตรงนี้ (imports หลัก)
|
||||
NotificationModule, // ✅ เพิ่มตรงนี้ เพื่อแก้ dependency index [13]
|
||||
WorkflowEngineModule,
|
||||
NotificationModule,
|
||||
],
|
||||
providers: [RfaService],
|
||||
providers: [RfaService, RfaWorkflowService],
|
||||
controllers: [RfaController],
|
||||
exports: [RfaService],
|
||||
})
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
// File: src/modules/rfa/rfa.service.ts
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, In } from 'typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
|
||||
// Entities
|
||||
import { Rfa } from './entities/rfa.entity.js';
|
||||
import { RfaRevision } from './entities/rfa-revision.entity.js';
|
||||
import { RfaItem } from './entities/rfa-item.entity.js';
|
||||
import { RfaType } from './entities/rfa-type.entity.js';
|
||||
import { RfaStatusCode } from './entities/rfa-status-code.entity.js';
|
||||
import { RfaApproveCode } from './entities/rfa-approve-code.entity.js';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity.js';
|
||||
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity.js';
|
||||
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity.js';
|
||||
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity.js';
|
||||
import { User } from '../user/entities/user.entity.js';
|
||||
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
|
||||
import { Correspondence } from '../correspondence/entities/correspondence.entity';
|
||||
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
|
||||
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
|
||||
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 { Rfa } from './entities/rfa.entity';
|
||||
|
||||
// DTOs
|
||||
import { CreateRfaDto } from './dto/create-rfa.dto.js';
|
||||
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto.js';
|
||||
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
|
||||
import { CreateRfaDto } from './dto/create-rfa.dto';
|
||||
|
||||
// Interfaces & Enums
|
||||
import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface.js';
|
||||
|
||||
// Services
|
||||
import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js';
|
||||
import { UserService } from '../user/user.service.js';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js';
|
||||
import { NotificationService } from '../notification/notification.service.js';
|
||||
import { SearchService } from '../search/search.service.js';
|
||||
import { DocumentNumberingService } from '../document-numbering/document-numbering.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { SearchService } from '../search/search.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
|
||||
|
||||
@Injectable()
|
||||
export class RfaService {
|
||||
@@ -87,6 +86,7 @@ export class RfaService {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine User Organization
|
||||
let userOrgId = user.primaryOrganizationId;
|
||||
if (!userOrgId) {
|
||||
const fullUser = await this.userService.findOne(user.user_id);
|
||||
@@ -101,14 +101,14 @@ export class RfaService {
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
const orgCode = 'ORG'; // TODO: Fetch real ORG Code
|
||||
const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Org Service if needed
|
||||
|
||||
// [FIXED] เรียกใช้แบบ Object Context พร้อม disciplineId
|
||||
// [UPDATED] Generate Document Number with Discipline
|
||||
const docNumber = await this.numberingService.generateNextNumber({
|
||||
projectId: createDto.projectId,
|
||||
originatorId: userOrgId,
|
||||
typeId: createDto.rfaTypeId, // RFA Type ใช้เป็น ID ในการนับเลข
|
||||
disciplineId: createDto.disciplineId, // สำคัญมากสำหรับ RFA (Req 6B)
|
||||
typeId: createDto.rfaTypeId,
|
||||
disciplineId: createDto.disciplineId ?? 0, // ✅ ส่ง disciplineId ไปด้วย (0 ถ้าไม่มี)
|
||||
year: new Date().getFullYear(),
|
||||
customTokens: {
|
||||
TYPE_CODE: rfaType.typeCode,
|
||||
@@ -116,24 +116,31 @@ export class RfaService {
|
||||
},
|
||||
});
|
||||
|
||||
// 1. Create Correspondence Record
|
||||
const correspondence = queryRunner.manager.create(Correspondence, {
|
||||
correspondenceNumber: docNumber,
|
||||
correspondenceTypeId: createDto.rfaTypeId, // Map RFA Type to Corr Type ID
|
||||
disciplineId: createDto.disciplineId, // บันทึก Discipline
|
||||
correspondenceTypeId: createDto.rfaTypeId, // Assuming RFA Type maps directly or via logic
|
||||
// Note: ถ้า CorrespondenceType แยก ID กับ RFA Type ต้อง Map ให้ถูก
|
||||
// ในที่นี้สมมติว่าใช้ ID เดียวกัน หรือ RFA Type เป็น SubType ของ Correspondence
|
||||
projectId: createDto.projectId,
|
||||
originatorId: userOrgId,
|
||||
isInternal: false,
|
||||
isInternalCommunication: false,
|
||||
createdBy: user.user_id,
|
||||
// ✅ Add disciplineId column if correspondence table supports it (as per Data Dictionary Update)
|
||||
// disciplineId: createDto.disciplineId
|
||||
});
|
||||
const savedCorr = await queryRunner.manager.save(correspondence);
|
||||
|
||||
// 2. Create RFA Master Record
|
||||
const rfa = queryRunner.manager.create(Rfa, {
|
||||
rfaTypeId: createDto.rfaTypeId,
|
||||
disciplineId: createDto.disciplineId, // บันทึก Discipline
|
||||
createdBy: user.user_id,
|
||||
// ✅ ถ้า Entity Rfa มี disciplineId ให้ใส่ตรงนี้ด้วย
|
||||
// disciplineId: createDto.disciplineId
|
||||
});
|
||||
const savedRfa = await queryRunner.manager.save(rfa);
|
||||
|
||||
// 3. Create First Revision (Draft)
|
||||
const rfaRevision = queryRunner.manager.create(RfaRevision, {
|
||||
correspondenceId: savedCorr.id,
|
||||
rfaId: savedRfa.id,
|
||||
@@ -147,9 +154,12 @@ export class RfaService {
|
||||
? new Date(createDto.documentDate)
|
||||
: new Date(),
|
||||
createdBy: user.user_id,
|
||||
details: createDto.details, // ✅ Save JSON Details
|
||||
schemaVersion: 1, // ✅ Default Schema Version
|
||||
});
|
||||
const savedRevision = await queryRunner.manager.save(rfaRevision);
|
||||
|
||||
// 4. Link Shop Drawings
|
||||
if (
|
||||
createDto.shopDrawingRevisionIds &&
|
||||
createDto.shopDrawingRevisionIds.length > 0
|
||||
@@ -164,7 +174,8 @@ export class RfaService {
|
||||
|
||||
const rfaItems = shopDrawings.map((sd) =>
|
||||
queryRunner.manager.create(RfaItem, {
|
||||
rfaRevisionId: savedCorr.id,
|
||||
rfaRevisionId: savedCorr.id, // ใช้ ID ของ Correspondence (ตาม Schema ที่ออกแบบไว้) หรือ RFA Revision ID แล้วแต่การ Map Entity
|
||||
// ตาม Entity RfaItem ที่ให้มา: rfaRevisionId map ไปที่ correspondence_id
|
||||
shopDrawingRevisionId: sd.id,
|
||||
}),
|
||||
);
|
||||
@@ -173,9 +184,10 @@ export class RfaService {
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// Indexing for Search
|
||||
this.searchService.indexDocument({
|
||||
id: savedCorr.id,
|
||||
type: 'correspondence',
|
||||
type: 'rfa',
|
||||
docNumber: docNumber,
|
||||
title: createDto.title,
|
||||
description: createDto.description,
|
||||
@@ -186,10 +198,8 @@ export class RfaService {
|
||||
|
||||
return {
|
||||
...savedRfa,
|
||||
currentRevision: {
|
||||
...savedRevision,
|
||||
correspondenceNumber: docNumber,
|
||||
},
|
||||
correspondenceNumber: docNumber,
|
||||
currentRevision: savedRevision,
|
||||
};
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
@@ -200,7 +210,8 @@ export class RfaService {
|
||||
}
|
||||
}
|
||||
|
||||
// ... (method อื่นๆ findOne, submit, processAction คงเดิม)
|
||||
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
|
||||
|
||||
async findOne(id: number) {
|
||||
const rfa = await this.rfaRepo.findOne({
|
||||
where: { id },
|
||||
@@ -230,14 +241,8 @@ export class RfaService {
|
||||
const rfa = await this.findOne(rfaId);
|
||||
const currentRevision = rfa.revisions.find((r) => r.isCurrent);
|
||||
|
||||
if (!currentRevision) {
|
||||
if (!currentRevision)
|
||||
throw new NotFoundException('Current revision not found');
|
||||
}
|
||||
|
||||
if (!currentRevision.correspondence) {
|
||||
throw new InternalServerErrorException('Correspondence relation missing');
|
||||
}
|
||||
|
||||
if (currentRevision.statusCode.statusCode !== 'DFT') {
|
||||
throw new BadRequestException('Only DRAFT documents can be submitted');
|
||||
}
|
||||
@@ -255,21 +260,21 @@ export class RfaService {
|
||||
const statusForApprove = await this.rfaStatusRepo.findOne({
|
||||
where: { statusCode: 'FAP' },
|
||||
});
|
||||
if (!statusForApprove) {
|
||||
if (!statusForApprove)
|
||||
throw new InternalServerErrorException('Status FAP not found');
|
||||
}
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// Update Revision Status
|
||||
currentRevision.rfaStatusCodeId = statusForApprove.id;
|
||||
currentRevision.issuedDate = new Date();
|
||||
await queryRunner.manager.save(currentRevision);
|
||||
|
||||
// Create First Routing Step
|
||||
const firstStep = template.steps[0];
|
||||
|
||||
const routing = queryRunner.manager.create(CorrespondenceRouting, {
|
||||
correspondenceId: currentRevision.correspondenceId,
|
||||
templateId: template.id,
|
||||
@@ -286,20 +291,18 @@ export class RfaService {
|
||||
});
|
||||
await queryRunner.manager.save(routing);
|
||||
|
||||
// Notify
|
||||
const recipientUserId = await this.userService.findDocControlIdByOrg(
|
||||
firstStep.toOrganizationId,
|
||||
);
|
||||
|
||||
if (recipientUserId) {
|
||||
const docNo = currentRevision.correspondence.correspondenceNumber;
|
||||
await this.notificationService.send({
|
||||
userId: recipientUserId,
|
||||
title: `RFA Submitted: ${currentRevision.title}`,
|
||||
message: `มีเอกสาร RFA ใหม่รอการตรวจสอบจากคุณ (เลขที่: ${docNo})`,
|
||||
message: `RFA ${currentRevision.correspondence.correspondenceNumber} submitted for approval.`,
|
||||
type: 'SYSTEM',
|
||||
entityType: 'rfa',
|
||||
entityId: rfa.id,
|
||||
link: `/rfas/${rfa.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,7 +310,6 @@ export class RfaService {
|
||||
return { message: 'RFA Submitted successfully', routing };
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(`Failed to submit RFA: ${(err as Error).message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
@@ -315,11 +317,13 @@ export class RfaService {
|
||||
}
|
||||
|
||||
async processAction(rfaId: number, dto: WorkflowActionDto, user: User) {
|
||||
// Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB
|
||||
// ใช้ this.workflowEngine.processAction (Legacy Support)
|
||||
// ... (สามารถใช้ Code เดิมจากที่คุณแนบมาได้เลย เพราะ Logic ถูกต้องแล้วสำหรับการใช้ CorrespondenceRouting) ...
|
||||
const rfa = await this.findOne(rfaId);
|
||||
const currentRevision = rfa.revisions.find((r) => r.isCurrent);
|
||||
if (!currentRevision) {
|
||||
if (!currentRevision)
|
||||
throw new NotFoundException('Current revision not found');
|
||||
}
|
||||
|
||||
const currentRouting = await this.routingRepo.findOne({
|
||||
where: {
|
||||
@@ -330,10 +334,8 @@ export class RfaService {
|
||||
relations: ['toOrganization'],
|
||||
});
|
||||
|
||||
if (!currentRouting) {
|
||||
if (!currentRouting)
|
||||
throw new BadRequestException('No active workflow step found');
|
||||
}
|
||||
|
||||
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
|
||||
throw new ForbiddenException(
|
||||
'You are not authorized to process this step',
|
||||
@@ -345,10 +347,10 @@ export class RfaService {
|
||||
relations: ['steps'],
|
||||
});
|
||||
|
||||
if (!template || !template.steps) {
|
||||
throw new InternalServerErrorException('Template or steps not found');
|
||||
}
|
||||
if (!template || !template.steps)
|
||||
throw new InternalServerErrorException('Template not found');
|
||||
|
||||
// Call Engine to calculate next step
|
||||
const result = this.workflowEngine.processAction(
|
||||
currentRouting.sequence,
|
||||
template.steps.length,
|
||||
@@ -361,19 +363,19 @@ export class RfaService {
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
currentRouting.status =
|
||||
dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
|
||||
// Update current routing
|
||||
currentRouting.status = dto.action === 'REJECT' ? 'REJECTED' : 'ACTIONED';
|
||||
currentRouting.processedByUserId = user.user_id;
|
||||
currentRouting.processedAt = new Date();
|
||||
currentRouting.comments = dto.comments;
|
||||
await queryRunner.manager.save(currentRouting);
|
||||
|
||||
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
|
||||
const nextStepConfig = template.steps.find(
|
||||
// Create next routing if available
|
||||
if (result.nextStepSequence && dto.action !== 'REJECT') {
|
||||
const nextStep = template.steps.find(
|
||||
(s) => s.sequence === result.nextStepSequence,
|
||||
);
|
||||
|
||||
if (nextStepConfig) {
|
||||
if (nextStep) {
|
||||
const nextRouting = queryRunner.manager.create(
|
||||
CorrespondenceRouting,
|
||||
{
|
||||
@@ -381,52 +383,43 @@ export class RfaService {
|
||||
templateId: template.id,
|
||||
sequence: result.nextStepSequence,
|
||||
fromOrganizationId: user.primaryOrganizationId,
|
||||
toOrganizationId: nextStepConfig.toOrganizationId,
|
||||
stepPurpose: nextStepConfig.stepPurpose,
|
||||
toOrganizationId: nextStep.toOrganizationId,
|
||||
stepPurpose: nextStep.stepPurpose,
|
||||
status: 'SENT',
|
||||
dueDate: new Date(
|
||||
Date.now() +
|
||||
(nextStepConfig.expectedDays || 7) * 24 * 60 * 60 * 1000,
|
||||
Date.now() + (nextStep.expectedDays || 7) * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
},
|
||||
);
|
||||
await queryRunner.manager.save(nextRouting);
|
||||
}
|
||||
} else if (
|
||||
result.nextStepSequence === null &&
|
||||
dto.action !== WorkflowAction.REJECT
|
||||
) {
|
||||
const approveCodeStr =
|
||||
dto.action === WorkflowAction.APPROVE ? '1A' : '4X';
|
||||
const approveCode = await this.rfaApproveRepo.findOne({
|
||||
where: { approveCode: approveCodeStr },
|
||||
});
|
||||
|
||||
if (approveCode) {
|
||||
currentRevision.rfaApproveCodeId = approveCode.id;
|
||||
currentRevision.approvedDate = new Date();
|
||||
}
|
||||
await queryRunner.manager.save(currentRevision);
|
||||
} else if (dto.action === WorkflowAction.REJECT) {
|
||||
const rejectCode = await this.rfaApproveRepo.findOne({
|
||||
where: { approveCode: '4X' },
|
||||
});
|
||||
if (rejectCode) {
|
||||
currentRevision.rfaApproveCodeId = rejectCode.id;
|
||||
} else if (result.nextStepSequence === null) {
|
||||
// Workflow Ended (Completed or Rejected)
|
||||
// Update RFA Status (Approved/Rejected Code)
|
||||
if (dto.action !== 'REJECT') {
|
||||
const approveCode = await this.rfaApproveRepo.findOne({
|
||||
where: { approveCode: dto.action === 'APPROVE' ? '1A' : '4X' },
|
||||
}); // Logic Map Code อย่างง่าย
|
||||
if (approveCode) {
|
||||
currentRevision.rfaApproveCodeId = approveCode.id;
|
||||
currentRevision.approvedDate = new Date();
|
||||
}
|
||||
} else {
|
||||
const rejectCode = await this.rfaApproveRepo.findOne({
|
||||
where: { approveCode: '4X' },
|
||||
});
|
||||
if (rejectCode) currentRevision.rfaApproveCodeId = rejectCode.id;
|
||||
}
|
||||
await queryRunner.manager.save(currentRevision);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
return { message: 'Action processed successfully', result };
|
||||
return { message: 'Action processed', result };
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Failed to process RFA action: ${(err as Error).message}`,
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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