251129:1700 update to 1.4.5

This commit is contained in:
admin
2025-11-29 16:50:34 +07:00
parent 6cafa6a2b9
commit 138b09d0c8
55 changed files with 14641 additions and 2090 deletions

View 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
}
]
}
```

View 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`
*/

View File

@@ -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}`);
}
}
};

View 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}`,
);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}
}

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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[];
}

View 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;
}

View File

@@ -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,
})

View File

@@ -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;

View File

@@ -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;

View 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}`,
);
}
}

View File

@@ -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,

View File

@@ -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],
})

View File

@@ -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();
}
}
}
}

View File

@@ -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>;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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' })

View File

@@ -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
}
}
}

View File

@@ -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' };
}
}

View File

@@ -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 {}

View File

@@ -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) {

View File

@@ -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)}`,
);
}
}