251128:1700 Backend to T3.1.1
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
// File: src/common/services/crypto.service.ts
|
||||
// บันทึกการแก้ไข: Encryption/Decryption Utility (T1.1)
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class CryptoService {
|
||||
private readonly logger = new Logger(CryptoService.name);
|
||||
private readonly algorithm = 'aes-256-cbc';
|
||||
private readonly key: Buffer;
|
||||
private readonly ivLength = 16;
|
||||
@@ -19,22 +20,42 @@ export class CryptoService {
|
||||
this.key = crypto.scryptSync(secret, 'salt', 32);
|
||||
}
|
||||
|
||||
encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(this.ivLength);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return `${iv.toString('hex')}:${encrypted}`;
|
||||
encrypt(text: string | number | boolean): string {
|
||||
if (text === null || text === undefined) return text as any;
|
||||
|
||||
try {
|
||||
const stringValue = String(text);
|
||||
const iv = crypto.randomBytes(this.ivLength);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
|
||||
let encrypted = cipher.update(stringValue, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return `${iv.toString('hex')}:${encrypted}`;
|
||||
} catch (error: any) {
|
||||
// Fix TS18046: Cast error to any or Error to access .message
|
||||
this.logger.error(`Encryption failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
decrypt(text: string): string {
|
||||
const [ivHex, encryptedHex] = text.split(':');
|
||||
if (!ivHex || !encryptedHex) return text;
|
||||
if (!text || typeof text !== 'string' || !text.includes(':')) return text;
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
try {
|
||||
const [ivHex, encryptedHex] = text.split(':');
|
||||
if (!ivHex || !encryptedHex) return text;
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
|
||||
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (error: any) {
|
||||
// Fix TS18046: Cast error to any or Error to access .message
|
||||
this.logger.warn(
|
||||
`Decryption failed for value. Returning original text. Error: ${error.message}`,
|
||||
);
|
||||
// กรณี Decrypt ไม่ได้ ให้คืนค่าเดิมเพื่อป้องกัน App Crash
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// File: src/modules/correspondence/dto/create-routing-template.dto.ts
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateRoutingTemplateStepDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
sequence!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
toOrganizationId!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
roleId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
stepPurpose?: string = 'FOR_REVIEW';
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
expectedDays?: number;
|
||||
}
|
||||
|
||||
export class CreateRoutingTemplateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
templateName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
projectId?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateRoutingTemplateStepDto)
|
||||
steps!: CreateRoutingTemplateStepDto[];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// File: src/modules/correspondence/entities/correspondence-revision.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
@@ -5,12 +6,16 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Correspondence } from './correspondence.entity.js';
|
||||
import { CorrespondenceStatus } from './correspondence-status.entity.js';
|
||||
import { User } from '../../user/entities/user.entity.js';
|
||||
|
||||
@Entity('correspondence_revisions')
|
||||
// ✅ เพิ่ม Index สำหรับ Virtual Columns เพื่อให้ Search เร็วขึ้น
|
||||
@Index('idx_corr_rev_v_project', ['vRefProjectId'])
|
||||
@Index('idx_corr_rev_v_type', ['vRefType'])
|
||||
export class CorrespondenceRevision {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
@@ -39,6 +44,27 @@ export class CorrespondenceRevision {
|
||||
@Column({ type: 'json', nullable: true })
|
||||
details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type
|
||||
|
||||
// ✅ [New] Virtual Column: ดึง Project ID จาก JSON details
|
||||
@Column({
|
||||
name: 'v_ref_project_id',
|
||||
type: 'int',
|
||||
generatedType: 'VIRTUAL',
|
||||
asExpression: "JSON_UNQUOTE(JSON_EXTRACT(details, '$.projectId'))",
|
||||
nullable: true,
|
||||
})
|
||||
vRefProjectId?: number;
|
||||
|
||||
// ✅ [New] Virtual Column: ดึง Document SubType จาก JSON details
|
||||
@Column({
|
||||
name: 'v_doc_subtype',
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
generatedType: 'VIRTUAL',
|
||||
asExpression: "JSON_UNQUOTE(JSON_EXTRACT(details, '$.subType'))",
|
||||
nullable: true,
|
||||
})
|
||||
vRefType?: string;
|
||||
|
||||
// Dates
|
||||
@Column({ name: 'document_date', type: 'date', nullable: true })
|
||||
documentDate?: Date;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// File: src/modules/correspondence/entities/correspondence-routing.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
import { CorrespondenceRevision } from './correspondence-revision.entity.js';
|
||||
import { Organization } from '../../project/entities/organization.entity.js';
|
||||
import { User } from '../../user/entities/user.entity.js';
|
||||
import { RoutingTemplate } from './routing-template.entity.js'; // <--- ✅ เพิ่ม Import นี้ครับ
|
||||
import { RoutingTemplate } from './routing-template.entity.js';
|
||||
|
||||
@Entity('correspondence_routings')
|
||||
export class CorrespondenceRouting {
|
||||
@@ -49,6 +50,10 @@ export class CorrespondenceRouting {
|
||||
@Column({ name: 'processed_at', type: 'datetime', nullable: true })
|
||||
processedAt?: Date;
|
||||
|
||||
// ✅ [New] เพิ่ม State Context เพื่อเก็บ Snapshot ข้อมูล Workflow ณ จุดนั้น
|
||||
@Column({ name: 'state_context', type: 'json', nullable: true })
|
||||
stateContext?: any;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@@ -57,7 +62,7 @@ export class CorrespondenceRouting {
|
||||
@JoinColumn({ name: 'correspondence_id' })
|
||||
correspondenceRevision?: CorrespondenceRevision;
|
||||
|
||||
@ManyToOne(() => RoutingTemplate) // ตอนนี้ TypeScript จะรู้จัก RoutingTemplate แล้ว
|
||||
@ManyToOne(() => RoutingTemplate)
|
||||
@JoinColumn({ name: 'template_id' })
|
||||
template?: RoutingTemplate;
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// File: src/modules/correspondence/entities/routing-template-step.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { RoutingTemplate } from './routing-template.entity.js';
|
||||
import { Organization } from '../../project/entities/organization.entity.js';
|
||||
import { Role } from '../../user/entities/role.entity.js';
|
||||
|
||||
@Entity('correspondence_routing_template_steps')
|
||||
export class RoutingTemplateStep {
|
||||
@@ -22,17 +24,27 @@ export class RoutingTemplateStep {
|
||||
@Column({ name: 'to_organization_id' })
|
||||
toOrganizationId!: number;
|
||||
|
||||
@Column({ name: 'role_id', nullable: true })
|
||||
roleId?: number;
|
||||
|
||||
@Column({ name: 'step_purpose', default: 'FOR_REVIEW' })
|
||||
stepPurpose!: string; // FOR_APPROVAL, FOR_REVIEW
|
||||
stepPurpose!: string;
|
||||
|
||||
@Column({ name: 'expected_days', nullable: true })
|
||||
expectedDays?: number;
|
||||
|
||||
@ManyToOne(() => RoutingTemplate, (t) => t.steps, { onDelete: 'CASCADE' })
|
||||
// Relations
|
||||
@ManyToOne(() => RoutingTemplate, (template) => template.steps, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'template_id' })
|
||||
template?: RoutingTemplate;
|
||||
|
||||
@ManyToOne(() => Organization)
|
||||
@JoinColumn({ name: 'to_organization_id' })
|
||||
toOrganization?: Organization;
|
||||
|
||||
@ManyToOne(() => Role)
|
||||
@JoinColumn({ name: 'role_id' })
|
||||
role?: Role;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// File: src/modules/json-schema/dto/create-json-schema.dto.ts
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
@@ -5,22 +6,65 @@ import {
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsObject,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class VirtualColumnConfigDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
json_path!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
column_name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
data_type!: 'INT' | 'VARCHAR' | 'BOOLEAN' | 'DATE' | 'DECIMAL' | 'DATETIME';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
index_type?: 'INDEX' | 'UNIQUE' | 'FULLTEXT';
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_required?: boolean;
|
||||
}
|
||||
|
||||
export class CreateJsonSchemaDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
schemaCode!: string; // รหัส Schema (ต้องไม่ซ้ำ เช่น 'RFA_DWG_V1')
|
||||
schemaCode!: string;
|
||||
|
||||
@IsString() // ✅ เพิ่ม Validation
|
||||
@IsNotEmpty()
|
||||
tableName!: string;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
version?: number; // เวอร์ชัน (Default: 1)
|
||||
version?: number;
|
||||
|
||||
@IsObject()
|
||||
@IsNotEmpty()
|
||||
schemaDefinition!: Record<string, any>; // โครงสร้าง JSON Schema (Standard Format)
|
||||
schemaDefinition!: Record<string, any>;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
uiSchema?: Record<string, any>;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => VirtualColumnConfigDto)
|
||||
virtualColumns?: VirtualColumnConfigDto[];
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
migrationScript?: Record<string, any>;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean; // สถานะการใช้งาน
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
19
backend/src/modules/json-schema/dto/migrate-data.dto.ts
Normal file
19
backend/src/modules/json-schema/dto/migrate-data.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// File: src/modules/json-schema/dto/migrate-data.dto.ts
|
||||
import { IsString, IsNotEmpty, IsInt, IsOptional } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MigrateDataDto {
|
||||
@ApiProperty({ description: 'The schema code to migrate to (e.g., RFA_DWG)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetSchemaCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Target version. If omitted, migrates to latest.',
|
||||
required: false,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
targetVersion?: number;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
// File: src/modules/json-schema/entities/json-schema.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export interface VirtualColumnConfig {
|
||||
json_path: string;
|
||||
column_name: string;
|
||||
data_type: 'INT' | 'VARCHAR' | 'BOOLEAN' | 'DATE' | 'DECIMAL' | 'DATETIME';
|
||||
index_type?: 'INDEX' | 'UNIQUE' | 'FULLTEXT';
|
||||
is_required: boolean;
|
||||
}
|
||||
|
||||
@Entity('json_schemas')
|
||||
@Index(['schemaCode', 'version'], { unique: true })
|
||||
export class JsonSchema {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'schema_code', unique: true, length: 100 })
|
||||
schemaCode!: string; // เช่น 'RFA_DWG_V1'
|
||||
@Column({ name: 'schema_code', length: 100 })
|
||||
schemaCode!: string;
|
||||
|
||||
@Column({ default: 1 })
|
||||
version!: number;
|
||||
|
||||
@Column({ name: 'table_name', length: 100, nullable: false }) // ✅ เพิ่ม: ระบุตารางเป้าหมาย
|
||||
tableName!: string;
|
||||
|
||||
@Column({ name: 'schema_definition', type: 'json' })
|
||||
schemaDefinition!: any; // เก็บ JSON Schema มาตรฐาน (Draft 7/2019-09)
|
||||
schemaDefinition!: any;
|
||||
|
||||
@Column({ name: 'ui_schema', type: 'json', nullable: true })
|
||||
uiSchema?: any;
|
||||
|
||||
@Column({ name: 'virtual_columns', type: 'json', nullable: true })
|
||||
virtualColumns?: VirtualColumnConfig[];
|
||||
|
||||
@Column({ name: 'migration_script', type: 'json', nullable: true })
|
||||
migrationScript?: any;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// File: src/modules/json-schema/interfaces/ui-schema.interface.ts
|
||||
|
||||
export type WidgetType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'radio'
|
||||
| 'checkbox'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'file-upload'
|
||||
| 'document-ref'; // Custom widget สำหรับอ้างอิงเอกสารอื่น
|
||||
|
||||
export type Operator =
|
||||
| 'equals'
|
||||
| 'notEquals'
|
||||
| 'contains'
|
||||
| 'greaterThan'
|
||||
| 'lessThan'
|
||||
| 'in';
|
||||
|
||||
export interface FieldCondition {
|
||||
field: string;
|
||||
operator: Operator;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface FieldDependency {
|
||||
condition: FieldCondition;
|
||||
actions: {
|
||||
visibility?: boolean; // true = show, false = hide
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
filterOptions?: Record<string, any>; // เช่น กรอง Dropdown ตามค่าที่เลือก
|
||||
};
|
||||
}
|
||||
|
||||
export interface UiSchemaField {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
widget?: WidgetType;
|
||||
title: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
enum?: any[]; // กรณีเป็น static options
|
||||
enumNames?: string[]; // label สำหรับ options
|
||||
dataSource?: string; // กรณีดึง options จาก API (เช่น 'master-data/disciplines')
|
||||
defaultValue?: any;
|
||||
readOnly?: boolean;
|
||||
hidden?: boolean;
|
||||
|
||||
// Validation & Rules
|
||||
required?: boolean;
|
||||
dependencies?: FieldDependency[];
|
||||
|
||||
// For Nested Structures
|
||||
properties?: { [key: string]: UiSchemaField };
|
||||
items?: UiSchemaField; // For arrays
|
||||
|
||||
// UI Grid Layout (Tailwind classes equivalent)
|
||||
colSpan?: number; // 1-12
|
||||
}
|
||||
|
||||
export interface LayoutGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'group' | 'section';
|
||||
fields: string[]; // Field keys ที่จะอยู่ในกลุ่มนี้
|
||||
}
|
||||
|
||||
export interface LayoutConfig {
|
||||
type: 'stack' | 'grid' | 'tabs' | 'steps' | 'wizard';
|
||||
groups: LayoutGroup[];
|
||||
options?: Record<string, any>; // Config เพิ่มเติมเฉพาะ Layout type
|
||||
}
|
||||
|
||||
export interface UiSchema {
|
||||
layout: LayoutConfig;
|
||||
fields: {
|
||||
[key: string]: UiSchemaField;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// File: src/modules/json-schema/interfaces/validation-result.interface.ts
|
||||
|
||||
export interface ValidationOptions {
|
||||
/**
|
||||
* ลบ field ที่ไม่ได้ระบุใน Schema ออกอัตโนมัติหรือไม่
|
||||
* Default: true
|
||||
*/
|
||||
removeAdditional?: boolean;
|
||||
|
||||
/**
|
||||
* แปลงชนิดข้อมูลอัตโนมัติถ้าเป็นไปได้ (เช่น "123" -> 123)
|
||||
* Default: true
|
||||
*/
|
||||
coerceTypes?: boolean;
|
||||
|
||||
/**
|
||||
* ใช้ค่า Default จาก Schema ถ้าข้อมูลไม่ถูกส่งมา
|
||||
* Default: true
|
||||
*/
|
||||
useDefaults?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidationErrorDetail {
|
||||
field: string;
|
||||
message: string;
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationErrorDetail[];
|
||||
sanitizedData: any;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,172 @@
|
||||
import { Controller, Post, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
// 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';
|
||||
|
||||
import { JsonSchemaService } from './json-schema.service';
|
||||
// ✅ FIX: Import DTO
|
||||
import { SchemaMigrationService } from './services/schema-migration.service';
|
||||
|
||||
import { CreateJsonSchemaDto } from './dto/create-json-schema.dto';
|
||||
// ✅ FIX: แก้ไข Path ของ Guards
|
||||
import { MigrateDataDto } from './dto/migrate-data.dto';
|
||||
import { SearchJsonSchemaDto } from './dto/search-json-schema.dto';
|
||||
import { UpdateJsonSchemaDto } from './dto/update-json-schema.dto';
|
||||
|
||||
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 { User } from '../user/entities/user.entity';
|
||||
|
||||
@ApiTags('JSON Schemas') // ✅ Add Swagger Tag
|
||||
@ApiTags('JSON Schemas Management')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Controller('json-schemas')
|
||||
export class JsonSchemaController {
|
||||
constructor(private readonly schemaService: JsonSchemaService) {}
|
||||
constructor(
|
||||
private readonly jsonSchemaService: JsonSchemaService,
|
||||
private readonly migrationService: SchemaMigrationService,
|
||||
) {}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Schema Management (CRUD)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create or Update JSON Schema' })
|
||||
@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.schemaService.createOrUpdate(
|
||||
createDto.schemaCode,
|
||||
createDto.schemaDefinition,
|
||||
);
|
||||
return this.jsonSchemaService.create(createDto);
|
||||
}
|
||||
|
||||
@Post(':code/validate')
|
||||
@ApiOperation({ summary: 'Test validation against a schema' })
|
||||
@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,
|
||||
) {
|
||||
return this.jsonSchemaService.update(id, updateDto);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Validation & Security
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@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) {
|
||||
const isValid = await this.schemaService.validate(code, data);
|
||||
return { valid: isValid, message: 'Validation passed' };
|
||||
// Note: Validation API นี้ใช้สำหรับ Test หรือ Pre-check เท่านั้น
|
||||
// การ Save จริงจะเรียกผ่าน Service ภายใน
|
||||
return this.jsonSchemaService.validateData(code, data);
|
||||
}
|
||||
|
||||
@Post('read/:code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Process read data (Decrypt & Filter) based on user roles',
|
||||
})
|
||||
@RequirePermission('document.view')
|
||||
async processReadData(
|
||||
@Param('code') code: string,
|
||||
@Body() data: any,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
// แปลง User Entity เป็น Security Context
|
||||
// ใช้ as any เพื่อ bypass type checking ชั่วคราว เนื่องจาก roles มักจะถูก inject เข้ามาใน request.user
|
||||
// โดย Strategy หรือ Guard แม้จะไม่มีใน Entity หลัก
|
||||
const userWithRoles = user as any;
|
||||
const userRoles = userWithRoles.roles
|
||||
? userWithRoles.roles.map((r: any) => r.roleName || r) // รองรับทั้ง Object Role และ String Role
|
||||
: [];
|
||||
|
||||
return this.jsonSchemaService.processReadData(code, data, { userRoles });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
// File: src/modules/json-schema/json-schema.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JsonSchemaService } from './json-schema.service';
|
||||
import { JsonSchemaController } from './json-schema.controller';
|
||||
|
||||
import { JsonSchema } from './entities/json-schema.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { JsonSchemaController } from './json-schema.controller';
|
||||
import { JsonSchemaService } from './json-schema.service';
|
||||
|
||||
import { JsonSecurityService } from './services/json-security.service';
|
||||
import { SchemaMigrationService } from './services/schema-migration.service';
|
||||
import { UiSchemaService } from './services/ui-schema.service';
|
||||
import { VirtualColumnService } from './services/virtual-column.service';
|
||||
// Fix TS2307: Correct path to CryptoService
|
||||
import { CryptoService } from '../../common/services/crypto.service';
|
||||
|
||||
// Import Module อื่นๆ ที่จำเป็นสำหรับ Guard (ถ้า Guards อยู่ใน Common อาจจะไม่ต้อง import ที่นี่โดยตรง)
|
||||
// import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([JsonSchema]), UserModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([JsonSchema]),
|
||||
ConfigModule,
|
||||
// UserModule,
|
||||
],
|
||||
controllers: [JsonSchemaController],
|
||||
providers: [JsonSchemaService],
|
||||
exports: [JsonSchemaService],
|
||||
providers: [
|
||||
JsonSchemaService,
|
||||
VirtualColumnService,
|
||||
UiSchemaService,
|
||||
SchemaMigrationService,
|
||||
JsonSecurityService,
|
||||
CryptoService,
|
||||
],
|
||||
exports: [JsonSchemaService, SchemaMigrationService, JsonSecurityService],
|
||||
})
|
||||
export class JsonSchemaModule {}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JsonSchemaService } from './json-schema.service';
|
||||
|
||||
describe('JsonSchemaService', () => {
|
||||
let service: JsonSchemaService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [JsonSchemaService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<JsonSchemaService>(JsonSchemaService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,107 +1,172 @@
|
||||
// File: src/modules/json-schema/json-schema.controller.ts
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import { JsonSchema } from './entities/json-schema.entity'; // ลบ .js
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
@Injectable()
|
||||
export class JsonSchemaService implements OnModuleInit {
|
||||
private ajv: Ajv;
|
||||
private validators = new Map<string, any>();
|
||||
private readonly logger = new Logger(JsonSchemaService.name);
|
||||
import { JsonSchemaService } from './json-schema.service';
|
||||
import { SchemaMigrationService } from './services/schema-migration.service';
|
||||
|
||||
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 { 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';
|
||||
|
||||
@ApiTags('JSON Schemas Management')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RbacGuard)
|
||||
@Controller('json-schemas')
|
||||
export class JsonSchemaController {
|
||||
constructor(
|
||||
@InjectRepository(JsonSchema)
|
||||
private schemaRepo: Repository<JsonSchema>,
|
||||
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,
|
||||
) {
|
||||
this.ajv = new Ajv({ allErrors: true, strict: false });
|
||||
addFormats(this.ajv);
|
||||
return this.jsonSchemaService.update(id, updateDto);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
// Pre-load schemas (Optional for performance)
|
||||
// const schemas = await this.schemaRepo.find({ where: { isActive: true } });
|
||||
// schemas.forEach(s => this.createValidator(s.schemaCode, s.schemaDefinition));
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบข้อมูล JSON ว่าถูกต้องตาม Schema หรือไม่
|
||||
*/
|
||||
async validate(schemaCode: string, data: any): Promise<boolean> {
|
||||
let validate = this.validators.get(schemaCode);
|
||||
// ----------------------------------------------------------------------
|
||||
// Validation & Security
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
if (!validate) {
|
||||
const schema = await this.schemaRepo.findOne({
|
||||
where: { schemaCode, isActive: true },
|
||||
});
|
||||
|
||||
if (!schema) {
|
||||
throw new NotFoundException(`JSON Schema '${schemaCode}' not found`);
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const valid = validate(data);
|
||||
|
||||
if (!valid) {
|
||||
const errors = validate.errors
|
||||
?.map((e: any) => `${e.instancePath} ${e.message}`)
|
||||
.join(', ');
|
||||
// โยน Error กลับไปเพื่อให้ Controller/Service ปลายทางจัดการ
|
||||
throw new BadRequestException(`JSON Validation Failed: ${errors}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
@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);
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้างหรืออัปเดต Schema
|
||||
*/
|
||||
async createOrUpdate(schemaCode: string, definition: Record<string, any>) {
|
||||
// 1. ตรวจสอบว่า Definition เป็น JSON Schema ที่ถูกต้องไหม
|
||||
try {
|
||||
this.ajv.compile(definition);
|
||||
} catch (error: any) {
|
||||
throw new BadRequestException(
|
||||
`Invalid JSON Schema format: ${error.message}`,
|
||||
);
|
||||
}
|
||||
@Post('read/:code')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Process read data (Decrypt & Filter) based on user roles',
|
||||
})
|
||||
@RequirePermission('document.view')
|
||||
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)
|
||||
: [];
|
||||
|
||||
// 2. บันทึกลง DB
|
||||
let schema = await this.schemaRepo.findOne({ where: { schemaCode } });
|
||||
return this.jsonSchemaService.processReadData(code, data, { userRoles });
|
||||
}
|
||||
|
||||
if (schema) {
|
||||
schema.schemaDefinition = definition;
|
||||
schema.version += 1;
|
||||
} else {
|
||||
schema = this.schemaRepo.create({
|
||||
schemaCode,
|
||||
schemaDefinition: definition,
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
// ----------------------------------------------------------------------
|
||||
// Data Migration
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const savedSchema = await this.schemaRepo.save(schema);
|
||||
|
||||
// 3. Clear Cache เพื่อให้ครั้งหน้าโหลดตัวใหม่
|
||||
this.validators.delete(schemaCode);
|
||||
this.logger.log(`Schema '${schemaCode}' updated (v${savedSchema.version})`);
|
||||
|
||||
return savedSchema;
|
||||
@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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// File: src/modules/json-schema/services/json-security.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CryptoService } from '../../../common/services/crypto.service';
|
||||
|
||||
export interface SecurityContext {
|
||||
userRoles: string[]; // Role ของ user ปัจจุบัน (เช่น ['EDITOR', 'viewer'])
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JsonSecurityService {
|
||||
constructor(private readonly cryptoService: CryptoService) {}
|
||||
|
||||
/**
|
||||
* ขาเข้า (Write): เข้ารหัสข้อมูล Sensitive ก่อนบันทึก
|
||||
*/
|
||||
encryptFields(data: any, schemaDefinition: any): any {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
const processed = Array.isArray(data) ? [...data] : { ...data };
|
||||
|
||||
// Traverse schema properties
|
||||
if (schemaDefinition.properties) {
|
||||
for (const [key, propSchema] of Object.entries<any>(
|
||||
schemaDefinition.properties,
|
||||
)) {
|
||||
if (data[key] !== undefined) {
|
||||
// 1. Check encryption flag
|
||||
if (propSchema['x-encrypt'] === true) {
|
||||
processed[key] = this.cryptoService.encrypt(data[key]);
|
||||
}
|
||||
|
||||
// 2. Recursive for nested objects/arrays
|
||||
if (propSchema.type === 'object' && propSchema.properties) {
|
||||
processed[key] = this.encryptFields(data[key], propSchema);
|
||||
} else if (propSchema.type === 'array' && propSchema.items) {
|
||||
if (Array.isArray(data[key])) {
|
||||
processed[key] = data[key].map((item: any) =>
|
||||
this.encryptFields(item, propSchema.items),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* ขาออก (Read): ถอดรหัส และ กรองข้อมูลตามสิทธิ์
|
||||
*/
|
||||
decryptAndFilterFields(
|
||||
data: any,
|
||||
schemaDefinition: any,
|
||||
context: SecurityContext,
|
||||
): any {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
|
||||
// Clone data to avoid mutation
|
||||
const processed = Array.isArray(data) ? [...data] : { ...data };
|
||||
|
||||
if (schemaDefinition.properties) {
|
||||
for (const [key, propSchema] of Object.entries<any>(
|
||||
schemaDefinition.properties,
|
||||
)) {
|
||||
if (data[key] !== undefined) {
|
||||
// 1. Decrypt (ถ้ามีค่าและถูกเข้ารหัสไว้)
|
||||
if (propSchema['x-encrypt'] === true) {
|
||||
processed[key] = this.cryptoService.decrypt(data[key]);
|
||||
}
|
||||
|
||||
// 2. Security Check (Role-based Access Control)
|
||||
if (propSchema['x-security']) {
|
||||
const rule = propSchema['x-security'];
|
||||
const requiredRoles = rule.roles || [];
|
||||
const hasPermission = requiredRoles.some(
|
||||
(role: string) =>
|
||||
context.userRoles.includes(role) ||
|
||||
context.userRoles.includes('SUPERADMIN'),
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
if (rule.onDeny === 'REMOVE') {
|
||||
delete processed[key];
|
||||
continue; // ข้ามไป field ถัดไป
|
||||
} else {
|
||||
// Default: MASK
|
||||
processed[key] = '********';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Recursive for nested objects/arrays
|
||||
// (ทำต่อเมื่อ Field ยังไม่ถูกลบ)
|
||||
if (processed[key] !== undefined) {
|
||||
if (propSchema.type === 'object' && propSchema.properties) {
|
||||
processed[key] = this.decryptAndFilterFields(
|
||||
processed[key],
|
||||
propSchema,
|
||||
context,
|
||||
);
|
||||
} else if (propSchema.type === 'array' && propSchema.items) {
|
||||
if (Array.isArray(processed[key])) {
|
||||
processed[key] = processed[key].map((item: any) =>
|
||||
this.decryptAndFilterFields(item, propSchema.items, context),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// File: src/modules/json-schema/services/schema-migration.service.ts
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { JsonSchemaService } from '../json-schema.service';
|
||||
|
||||
export interface MigrationStep {
|
||||
type:
|
||||
| 'FIELD_RENAME'
|
||||
| 'FIELD_TRANSFORM'
|
||||
| 'FIELD_ADD'
|
||||
| 'FIELD_REMOVE'
|
||||
| 'STRUCTURE_CHANGE';
|
||||
config: any;
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
success: boolean;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
migratedFields: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchemaMigrationService {
|
||||
private readonly logger = new Logger(SchemaMigrationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly jsonSchemaService: JsonSchemaService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Migrate data for a specific entity to a target schema version
|
||||
*/
|
||||
async migrateData(
|
||||
entityType: string, // e.g., 'rfa_revisions', 'correspondence_revisions'
|
||||
entityId: number,
|
||||
targetSchemaCode: string,
|
||||
targetVersion?: number,
|
||||
): Promise<MigrationResult> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 1. Get Target Schema
|
||||
let targetSchema;
|
||||
if (targetVersion) {
|
||||
targetSchema = await this.jsonSchemaService.findOneByCodeAndVersion(
|
||||
targetSchemaCode,
|
||||
targetVersion,
|
||||
);
|
||||
} else {
|
||||
targetSchema =
|
||||
await this.jsonSchemaService.findLatestByCode(targetSchemaCode);
|
||||
}
|
||||
|
||||
// 2. Fetch Entity Data & Current Version
|
||||
// Note: This assumes the entity table has 'details' (json) and 'schema_version' (int) columns
|
||||
// If schema_version is not present, we assume version 1
|
||||
const entity = await queryRunner.manager.query(
|
||||
`SELECT details, schema_version FROM ${entityType} WHERE id = ?`,
|
||||
[entityId],
|
||||
);
|
||||
|
||||
if (!entity || entity.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Entity ${entityType} with ID ${entityId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const currentData = entity[0].details || {};
|
||||
const currentVersion = entity[0].schema_version || 1;
|
||||
|
||||
if (currentVersion >= targetSchema.version) {
|
||||
return {
|
||||
success: true,
|
||||
fromVersion: currentVersion,
|
||||
toVersion: currentVersion,
|
||||
migratedFields: [], // No migration needed
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Find Migration Path (Iterative Upgrade)
|
||||
let migratedData = JSON.parse(JSON.stringify(currentData));
|
||||
const migratedFields: string[] = [];
|
||||
|
||||
// Loop from current version up to target version
|
||||
for (let v = currentVersion + 1; v <= targetSchema.version; v++) {
|
||||
const schemaVer = await this.jsonSchemaService.findOneByCodeAndVersion(
|
||||
targetSchemaCode,
|
||||
v,
|
||||
);
|
||||
|
||||
if (schemaVer && schemaVer.migrationScript) {
|
||||
this.logger.log(
|
||||
`Applying migration script for ${targetSchemaCode} v${v}...`,
|
||||
);
|
||||
|
||||
const script = schemaVer.migrationScript;
|
||||
|
||||
// Apply steps defined in migrationScript
|
||||
if (Array.isArray(script.steps)) {
|
||||
for (const step of script.steps) {
|
||||
migratedData = await this.applyMigrationStep(step, migratedData);
|
||||
if (step.config.field || step.config.new_field) {
|
||||
migratedFields.push(step.config.new_field || step.config.field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate Migrated Data against Target Schema
|
||||
const validation = await this.jsonSchemaService.validateData(
|
||||
targetSchema.schemaCode,
|
||||
migratedData,
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new BadRequestException(
|
||||
`Migration failed: Resulting data does not match target schema v${targetSchema.version}. Errors: ${JSON.stringify(validation.errors)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Save Migrated Data
|
||||
// Update details AND schema_version
|
||||
await queryRunner.manager.query(
|
||||
`UPDATE ${entityType} SET details = ?, schema_version = ? WHERE id = ?`,
|
||||
[
|
||||
JSON.stringify(validation.sanitizedData),
|
||||
targetSchema.version,
|
||||
entityId,
|
||||
],
|
||||
);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fromVersion: currentVersion,
|
||||
toVersion: targetSchema.version,
|
||||
migratedFields: [...new Set(migratedFields)],
|
||||
};
|
||||
} catch (err: any) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(`Migration failed: ${err.message}`, err.stack);
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single migration step
|
||||
*/
|
||||
private async applyMigrationStep(
|
||||
step: MigrationStep,
|
||||
data: any,
|
||||
): Promise<any> {
|
||||
const newData = { ...data };
|
||||
|
||||
switch (step.type) {
|
||||
case 'FIELD_RENAME':
|
||||
if (newData[step.config.old_field] !== undefined) {
|
||||
newData[step.config.new_field] = newData[step.config.old_field];
|
||||
delete newData[step.config.old_field];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'FIELD_ADD':
|
||||
if (newData[step.config.field] === undefined) {
|
||||
newData[step.config.field] = step.config.default_value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'FIELD_REMOVE':
|
||||
delete newData[step.config.field];
|
||||
break;
|
||||
|
||||
case 'FIELD_TRANSFORM':
|
||||
if (newData[step.config.field] !== undefined) {
|
||||
// Simple transform logic (e.g., map values)
|
||||
if (step.config.transform === 'MAP_VALUES' && step.config.mapping) {
|
||||
const oldVal = newData[step.config.field];
|
||||
newData[step.config.field] = step.config.mapping[oldVal] || oldVal;
|
||||
}
|
||||
// Type casting
|
||||
else if (step.config.transform === 'TO_NUMBER') {
|
||||
newData[step.config.field] = Number(newData[step.config.field]);
|
||||
} else if (step.config.transform === 'TO_STRING') {
|
||||
newData[step.config.field] = String(newData[step.config.field]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown migration step type: ${step.type}`);
|
||||
}
|
||||
|
||||
return newData;
|
||||
}
|
||||
}
|
||||
|
||||
115
backend/src/modules/json-schema/services/ui-schema.service.ts
Normal file
115
backend/src/modules/json-schema/services/ui-schema.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// File: src/modules/json-schema/services/ui-schema.service.ts
|
||||
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { UiSchema, UiSchemaField } from '../interfaces/ui-schema.interface';
|
||||
|
||||
@Injectable()
|
||||
export class UiSchemaService {
|
||||
private readonly logger = new Logger(UiSchemaService.name);
|
||||
|
||||
/**
|
||||
* ตรวจสอบความถูกต้องของ UI Schema
|
||||
*/
|
||||
validateUiSchema(uiSchema: UiSchema, dataSchema: any): boolean {
|
||||
if (!uiSchema) return true; // Optional field
|
||||
|
||||
// 1. Validate Structure เบื้องต้น
|
||||
if (!uiSchema.layout || !uiSchema.fields) {
|
||||
throw new BadRequestException(
|
||||
'UI Schema must contain "layout" and "fields" properties.',
|
||||
);
|
||||
}
|
||||
|
||||
// 2. ตรวจสอบว่า Fields ใน Layout มีคำนิยามครบถ้วน
|
||||
const definedFields = new Set(Object.keys(uiSchema.fields));
|
||||
const layoutFields = new Set<string>();
|
||||
|
||||
uiSchema.layout.groups.forEach((group) => {
|
||||
group.fields.forEach((fieldKey) => {
|
||||
layoutFields.add(fieldKey);
|
||||
if (!definedFields.has(fieldKey)) {
|
||||
throw new BadRequestException(
|
||||
`Field "${fieldKey}" used in layout "${group.title}" is not defined in "fields".`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 3. (Optional) ตรวจสอบว่า Fields ใน Data Schema (AJV) มีครบใน UI Schema หรือไม่
|
||||
// ถ้า Data Schema บอกว่ามี field 'title' แต่ UI Schema ไม่มี -> Frontend อาจจะไม่เรนเดอร์
|
||||
if (dataSchema && dataSchema.properties) {
|
||||
const dataKeys = Object.keys(dataSchema.properties);
|
||||
const missingFields = dataKeys.filter((key) => !definedFields.has(key));
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
this.logger.warn(
|
||||
`Data schema properties [${missingFields.join(', ')}] are missing from UI Schema.`,
|
||||
);
|
||||
// ไม่ Throw Error เพราะบางทีเราอาจตั้งใจซ่อน Field (Hidden field)
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง UI Schema พื้นฐานจาก Data Schema (AJV) อัตโนมัติ
|
||||
* ใช้กรณี user ไม่ได้ส่ง UI Schema มาให้
|
||||
*/
|
||||
generateDefaultUiSchema(dataSchema: any): UiSchema {
|
||||
if (!dataSchema || !dataSchema.properties) {
|
||||
return {
|
||||
layout: { type: 'stack', groups: [] },
|
||||
fields: {},
|
||||
};
|
||||
}
|
||||
|
||||
const fields: { [key: string]: UiSchemaField } = {};
|
||||
const groupFields: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries<any>(dataSchema.properties)) {
|
||||
groupFields.push(key);
|
||||
|
||||
fields[key] = {
|
||||
type: value.type || 'string',
|
||||
title: value.title || this.humanize(key),
|
||||
description: value.description,
|
||||
required: (dataSchema.required || []).includes(key),
|
||||
widget: this.guessWidget(value),
|
||||
colSpan: 12, // Default full width
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
layout: {
|
||||
type: 'stack',
|
||||
groups: [
|
||||
{
|
||||
id: 'default',
|
||||
title: 'General Information',
|
||||
type: 'section',
|
||||
fields: groupFields,
|
||||
},
|
||||
],
|
||||
},
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private humanize(str: string): string {
|
||||
return str
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
private guessWidget(schemaProp: any): any {
|
||||
if (schemaProp.enum) return 'select';
|
||||
if (schemaProp.type === 'boolean') return 'checkbox';
|
||||
if (schemaProp.format === 'date') return 'date';
|
||||
if (schemaProp.format === 'date-time') return 'datetime';
|
||||
if (schemaProp.format === 'binary') return 'file-upload';
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// File: src/modules/json-schema/services/virtual-column.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, QueryRunner } from 'typeorm';
|
||||
import { VirtualColumnConfig } from '../entities/json-schema.entity';
|
||||
|
||||
@Injectable()
|
||||
export class VirtualColumnService {
|
||||
private readonly logger = new Logger(VirtualColumnService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* สร้าง/อัปเดต Virtual Columns และ Index บน Database จริง
|
||||
*/
|
||||
async setupVirtualColumns(tableName: string, configs: VirtualColumnConfig[]) {
|
||||
if (!configs || configs.length === 0) return;
|
||||
|
||||
// ใช้ QueryRunner เพื่อให้จัดการ Transaction หรือ Connection ได้ละเอียด
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
|
||||
try {
|
||||
this.logger.log(
|
||||
`Start setting up virtual columns for table '${tableName}'...`,
|
||||
);
|
||||
|
||||
// 1. ตรวจสอบว่าตารางมีอยู่จริงไหม
|
||||
const tableExists = await queryRunner.hasTable(tableName);
|
||||
if (!tableExists) {
|
||||
this.logger.warn(
|
||||
`Table '${tableName}' not found. Skipping virtual columns.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const config of configs) {
|
||||
await this.ensureVirtualColumn(queryRunner, tableName, config);
|
||||
|
||||
if (config.index_type) {
|
||||
await this.ensureIndex(queryRunner, tableName, config);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Finished setting up virtual columns for '${tableName}'.`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`Failed to setup virtual columns: ${err.message}`,
|
||||
err.stack,
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง Column ถ้ายังไม่มี
|
||||
*/
|
||||
private async ensureVirtualColumn(
|
||||
queryRunner: QueryRunner,
|
||||
tableName: string,
|
||||
config: VirtualColumnConfig,
|
||||
) {
|
||||
const hasColumn = await queryRunner.hasColumn(
|
||||
tableName,
|
||||
config.column_name,
|
||||
);
|
||||
|
||||
if (!hasColumn) {
|
||||
const sql = this.generateAddColumnSql(tableName, config);
|
||||
this.logger.log(`Executing: ${sql}`);
|
||||
await queryRunner.query(sql);
|
||||
} else {
|
||||
// TODO: (Advance) ถ้ามี Column แล้ว แต่ Definition เปลี่ยน อาจต้อง ALTER MODIFY
|
||||
this.logger.debug(
|
||||
`Column '${config.column_name}' already exists in '${tableName}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง Index ถ้ายังไม่มี
|
||||
*/
|
||||
private async ensureIndex(
|
||||
queryRunner: QueryRunner,
|
||||
tableName: string,
|
||||
config: VirtualColumnConfig,
|
||||
) {
|
||||
const indexName = `idx_${tableName}_${config.column_name}`;
|
||||
|
||||
// ตรวจสอบว่า Index มีอยู่จริงไหม (Query จาก information_schema เพื่อความชัวร์)
|
||||
const checkIndexSql = `
|
||||
SELECT COUNT(1) as count
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
AND index_name = ?
|
||||
`;
|
||||
const result = await queryRunner.query(checkIndexSql, [
|
||||
tableName,
|
||||
indexName,
|
||||
]);
|
||||
|
||||
if (result[0].count == 0) {
|
||||
const sql = `CREATE ${config.index_type === 'UNIQUE' ? 'UNIQUE' : ''} INDEX ${indexName} ON ${tableName} (${config.column_name})`;
|
||||
this.logger.log(`Creating Index: ${sql}`);
|
||||
await queryRunner.query(sql);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL สำหรับ MariaDB 10.11 Virtual Column
|
||||
* Syntax: ADD COLUMN name type GENERATED ALWAYS AS (expr) VIRTUAL
|
||||
*/
|
||||
private generateAddColumnSql(
|
||||
tableName: string,
|
||||
config: VirtualColumnConfig,
|
||||
): string {
|
||||
const dbType = this.mapDataTypeToSql(config.data_type);
|
||||
// JSON_UNQUOTE(JSON_EXTRACT(details, '$.path'))
|
||||
// ใช้ 'details' เป็นชื่อ column JSON หลัก (ต้องตรงกับ Database Schema ที่ออกแบบไว้)
|
||||
const expression = `JSON_UNQUOTE(JSON_EXTRACT(details, '${config.json_path}'))`;
|
||||
|
||||
// Handle Type Casting inside expression if needed,
|
||||
// but usually MariaDB handles string return from JSON_EXTRACT.
|
||||
// For INT/DATE, virtual column type definition enforces it.
|
||||
|
||||
return `ALTER TABLE ${tableName} ADD COLUMN ${config.column_name} ${dbType} GENERATED ALWAYS AS (${expression}) VIRTUAL`;
|
||||
}
|
||||
|
||||
private mapDataTypeToSql(type: string): string {
|
||||
switch (type) {
|
||||
case 'INT':
|
||||
return 'INT';
|
||||
case 'VARCHAR':
|
||||
return 'VARCHAR(255)';
|
||||
case 'BOOLEAN':
|
||||
return 'TINYINT(1)';
|
||||
case 'DATE':
|
||||
return 'DATE';
|
||||
case 'DATETIME':
|
||||
return 'DATETIME';
|
||||
case 'DECIMAL':
|
||||
return 'DECIMAL(10,2)';
|
||||
default:
|
||||
return 'VARCHAR(255)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
backend/src/modules/rfa/dto/create-rfa-revision.dto.ts
Normal file
52
backend/src/modules/rfa/dto/create-rfa-revision.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// File: src/modules/rfa/dto/create-rfa-revision.dto.ts
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateRfaRevisionDto {
|
||||
@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;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
details?: Record<string, any>;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
shopDrawingRevisionIds?: number[]; // IDs of linked Shop Drawings
|
||||
}
|
||||
37
backend/src/modules/rfa/dto/create-rfa-workflow.dto.ts
Normal file
37
backend/src/modules/rfa/dto/create-rfa-workflow.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// File: src/modules/rfa/dto/create-rfa-workflow.dto.ts
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum RfaActionType {
|
||||
REVIEW = 'REVIEW',
|
||||
APPROVE = 'APPROVE',
|
||||
ACKNOWLEDGE = 'ACKNOWLEDGE',
|
||||
}
|
||||
|
||||
export class CreateRfaWorkflowDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
stepNumber!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
organizationId!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
assignedTo?: number;
|
||||
|
||||
@IsEnum(RfaActionType)
|
||||
@IsOptional()
|
||||
actionType?: RfaActionType;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comments?: string;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,26 @@
|
||||
// File: src/modules/rfa/dto/create-rfa.dto.ts
|
||||
// File: src/modules/rfa/dto/create-rfa-revision.dto.ts
|
||||
import {
|
||||
IsInt,
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateRfaDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
projectId!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
rfaTypeId!: number;
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
disciplineId?: number; // [Req 6B] สาขางาน (จำเป็นสำหรับการรันเลข RFA)
|
||||
|
||||
export class CreateRfaRevisionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
toOrganizationId!: number; // ส่งถึงใคร (สำหรับ Routing Step 1)
|
||||
rfaStatusCodeId!: number;
|
||||
|
||||
@IsString()
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
rfaApproveCodeId?: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
@@ -39,10 +28,25 @@ export class CreateRfaDto {
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
dueDate?: string; // กำหนดวันตอบกลับ
|
||||
issuedDate?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
receivedDate?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
approvedDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
details?: Record<string, any>;
|
||||
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@IsOptional()
|
||||
shopDrawingRevisionIds?: number[]; // Shop Drawings ที่แนบมา
|
||||
}
|
||||
shopDrawingRevisionIds?: number[]; // IDs of linked Shop Drawings
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRfaDto } from './create-rfa.dto';
|
||||
import { CreateRfaRevisionDto } from './create-rfa-revision.dto';
|
||||
|
||||
export class UpdateRfaDto extends PartialType(CreateRfaDto) {}
|
||||
export class UpdateRfaDto extends PartialType(CreateRfaRevisionDto) {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// File: src/modules/rfa/entities/rfa-revision.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
Unique,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Rfa } from './rfa.entity';
|
||||
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
|
||||
@@ -15,7 +17,7 @@ import { RfaStatusCode } from './rfa-status-code.entity';
|
||||
import { RfaApproveCode } from './rfa-approve-code.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { RfaItem } from './rfa-item.entity';
|
||||
import { RfaWorkflow } from './rfa-workflow.entity'; // Import เพิ่ม
|
||||
import { RfaWorkflow } from './rfa-workflow.entity';
|
||||
|
||||
@Entity('rfa_revisions')
|
||||
@Unique(['rfaId', 'revisionNumber'])
|
||||
@@ -63,6 +65,20 @@ export class RfaRevision {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
// ✅ [New] เพิ่ม field details สำหรับเก็บข้อมูล Dynamic ของ RFA (เช่น Method Statement Details)
|
||||
@Column({ type: 'json', nullable: true })
|
||||
details?: any;
|
||||
|
||||
// ✅ [New] Virtual Column: ดึงจำนวนแบบที่แนบ (drawingCount) จาก JSON
|
||||
@Column({
|
||||
name: 'v_ref_drawing_count',
|
||||
type: 'int',
|
||||
generatedType: 'VIRTUAL',
|
||||
asExpression: "JSON_UNQUOTE(JSON_EXTRACT(details, '$.drawingCount'))",
|
||||
nullable: true,
|
||||
})
|
||||
vRefDrawingCount?: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// File: src/modules/workflow-engine/entities/workflow-history.entity.ts
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { WorkflowInstance } from './workflow-instance.entity';
|
||||
|
||||
@Entity('workflow_histories')
|
||||
export class WorkflowHistory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => WorkflowInstance, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'instance_id' })
|
||||
instance!: WorkflowInstance;
|
||||
|
||||
@Column({ name: 'instance_id' })
|
||||
instanceId!: string;
|
||||
|
||||
@Column({ name: 'from_state', length: 50 })
|
||||
fromState!: string;
|
||||
|
||||
@Column({ name: 'to_state', length: 50 })
|
||||
toState!: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
action!: string;
|
||||
|
||||
@Column({ name: 'action_by_user_id', nullable: true })
|
||||
actionByUserId?: number; // User ID ของผู้ดำเนินการ
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
comment?: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
metadata?: Record<string, any>; // เก็บข้อมูลเพิ่มเติม เช่น Snapshot ของ Context ณ ตอนนั้น
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// File: src/modules/workflow-engine/entities/workflow-instance.entity.ts
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { WorkflowDefinition } from './workflow-definition.entity';
|
||||
|
||||
export enum WorkflowStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
TERMINATED = 'TERMINATED',
|
||||
}
|
||||
|
||||
@Entity('workflow_instances')
|
||||
@Index(['entityType', 'entityId']) // Index สำหรับค้นหาตามเอกสาร
|
||||
@Index(['currentState']) // Index สำหรับ Filter ตามสถานะ
|
||||
export class WorkflowInstance {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// เชื่อมโยงกับ Definition ที่ใช้ตอนสร้าง Instance นี้
|
||||
@ManyToOne(() => WorkflowDefinition)
|
||||
@JoinColumn({ name: 'definition_id' })
|
||||
definition!: WorkflowDefinition;
|
||||
|
||||
@Column({ name: 'definition_id' })
|
||||
definitionId!: string;
|
||||
|
||||
// Polymorphic Relation: เชื่อมกับเอกสารได้หลายประเภท (RFA, CORR, etc.)
|
||||
@Column({ name: 'entity_type', length: 50 })
|
||||
entityType!: string;
|
||||
|
||||
@Column({ name: 'entity_id', length: 50 })
|
||||
entityId!: string; // รองรับทั้ง ID แบบ Int และ UUID (เก็บเป็น String)
|
||||
|
||||
@Column({ name: 'current_state', length: 50 })
|
||||
currentState!: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: WorkflowStatus,
|
||||
default: WorkflowStatus.ACTIVE,
|
||||
})
|
||||
status!: WorkflowStatus;
|
||||
|
||||
// Context เฉพาะของ Instance นี้ (เช่น ตัวแปรที่ส่งต่อระหว่าง State)
|
||||
@Column({ type: 'json', nullable: true })
|
||||
context?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// File: src/modules/workflow-engine/workflow-dsl.service.ts
|
||||
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
export interface WorkflowState {
|
||||
initial?: boolean;
|
||||
@@ -17,7 +17,7 @@ export interface TransitionRule {
|
||||
export interface RequirementRule {
|
||||
role?: string;
|
||||
user?: string;
|
||||
condition?: string; // e.g. "amount > 5000" (Advanced)
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface EventRule {
|
||||
@@ -36,11 +36,12 @@ export interface CompiledWorkflow {
|
||||
export class WorkflowDslService {
|
||||
/**
|
||||
* คอมไพล์ DSL Input ให้เป็น Standard Execution Tree
|
||||
* @param dsl ข้อมูลดิบจาก User (JSON/Object)
|
||||
* @returns CompiledWorkflow Object ที่พร้อมใช้งาน
|
||||
*/
|
||||
compile(dsl: any): CompiledWorkflow {
|
||||
// 1. Basic Structure Validation
|
||||
if (!dsl || typeof dsl !== 'object') {
|
||||
throw new BadRequestException('DSL must be a valid JSON object.');
|
||||
}
|
||||
|
||||
if (!dsl.states || !Array.isArray(dsl.states)) {
|
||||
throw new BadRequestException(
|
||||
'DSL syntax error: "states" array is required.',
|
||||
@@ -55,7 +56,6 @@ export class WorkflowDslService {
|
||||
|
||||
const stateMap = new Set<string>();
|
||||
|
||||
// 2. First Pass: Collect all state names and normalize structure
|
||||
for (const rawState of dsl.states) {
|
||||
if (!rawState.name) {
|
||||
throw new BadRequestException(
|
||||
@@ -71,7 +71,6 @@ export class WorkflowDslService {
|
||||
transitions: {},
|
||||
};
|
||||
|
||||
// Normalize transitions "on:"
|
||||
if (rawState.on) {
|
||||
for (const [action, rule] of Object.entries(rawState.on)) {
|
||||
const rawRule = rule as any;
|
||||
@@ -86,15 +85,11 @@ export class WorkflowDslService {
|
||||
compiled.states[rawState.name] = normalizedState;
|
||||
}
|
||||
|
||||
// 3. Second Pass: Validate Integrity
|
||||
this.validateIntegrity(compiled, stateMap);
|
||||
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบความสมบูรณ์ของ Workflow Logic
|
||||
*/
|
||||
private validateIntegrity(compiled: CompiledWorkflow, stateMap: Set<string>) {
|
||||
let hasInitial = false;
|
||||
|
||||
@@ -107,19 +102,13 @@ export class WorkflowDslService {
|
||||
hasInitial = true;
|
||||
}
|
||||
|
||||
// ตรวจสอบ Transitions
|
||||
if (state.transitions) {
|
||||
for (const [action, rule] of Object.entries(state.transitions)) {
|
||||
// 1. ปลายทางต้องมีอยู่จริง
|
||||
if (!stateMap.has(rule.to)) {
|
||||
throw new BadRequestException(
|
||||
`DSL Error: State "${stateName}" transitions via "${action}" to unknown state "${rule.to}".`,
|
||||
);
|
||||
}
|
||||
// 2. Action name convention (Optional but recommended)
|
||||
if (!/^[A-Z0-9_]+$/.test(action)) {
|
||||
// Warning or Strict Error could be here
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,18 +118,11 @@ export class WorkflowDslService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ประเมินผล (Evaluate) การเปลี่ยนสถานะ
|
||||
* @param compiled ข้อมูล Workflow ที่ Compile แล้ว
|
||||
* @param currentState สถานะปัจจุบัน
|
||||
* @param action การกระทำ
|
||||
* @param context ข้อมูลประกอบ (User roles, etc.)
|
||||
*/
|
||||
evaluate(
|
||||
compiled: CompiledWorkflow,
|
||||
currentState: string,
|
||||
action: string,
|
||||
context: any,
|
||||
context: any = {}, // Default empty object
|
||||
): { nextState: string; events: EventRule[] } {
|
||||
const stateConfig = compiled.states[currentState];
|
||||
|
||||
@@ -164,7 +146,6 @@ export class WorkflowDslService {
|
||||
);
|
||||
}
|
||||
|
||||
// Check Requirements (RBAC Logic inside Engine)
|
||||
if (transition.requirements && transition.requirements.length > 0) {
|
||||
this.checkRequirements(transition.requirements, context);
|
||||
}
|
||||
@@ -175,22 +156,19 @@ export class WorkflowDslService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบเงื่อนไขสิทธิ์ (Requirements)
|
||||
*/
|
||||
private checkRequirements(requirements: RequirementRule[], context: any) {
|
||||
const userRoles = context.roles || [];
|
||||
const userId = context.userId;
|
||||
const safeContext = context || {};
|
||||
const userRoles = safeContext.roles || [];
|
||||
const userId = safeContext.userId;
|
||||
|
||||
const isAllowed = requirements.some((req) => {
|
||||
// กรณีเช็ค Role
|
||||
if (req.role) {
|
||||
return userRoles.includes(req.role);
|
||||
}
|
||||
// กรณีเช็ค Specific User
|
||||
if (req.user) {
|
||||
return userId === req.user;
|
||||
}
|
||||
// Future: Add Condition Logic Evaluation here
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
// File: src/modules/workflow-engine/workflow-engine.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Patch,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'; // เพิ่ม Patch, Param
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
} 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'; // [NEW]
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto'; // [NEW]
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { GetAvailableActionsDto } from './dto/get-available-actions.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
|
||||
@ApiTags('Workflow Engine (DSL)')
|
||||
@Controller('workflow-engine')
|
||||
@UseGuards(JwtAuthGuard) // Protect all endpoints
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WorkflowEngineController {
|
||||
constructor(private readonly workflowService: WorkflowEngineService) {}
|
||||
|
||||
@@ -42,24 +43,20 @@ export class WorkflowEngineController {
|
||||
@Get('actions')
|
||||
@ApiOperation({ summary: 'Get available actions for current state' })
|
||||
async getAvailableActions(@Query() query: GetAvailableActionsDto) {
|
||||
// [UPDATED] ใช้ DTO แทนแยก Query
|
||||
return this.workflowService.getAvailableActions(
|
||||
query.workflow_code,
|
||||
query.current_state,
|
||||
);
|
||||
}
|
||||
|
||||
// [OPTIONAL/RECOMMENDED] เพิ่ม Endpoint สำหรับ Update (PATCH)
|
||||
@Patch('definitions/:id')
|
||||
@ApiOperation({
|
||||
summary: 'Update workflow status or details (e.g. Deactivate)',
|
||||
summary: 'Update workflow status or details (DSL Re-compile)',
|
||||
})
|
||||
async updateDefinition(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateWorkflowDefinitionDto, // [NEW] ใช้ Update DTO
|
||||
@Param('id', ParseUUIDPipe) id: string, // เพิ่ม ParseUUIDPipe เพื่อ Validate ID
|
||||
@Body() dto: UpdateWorkflowDefinitionDto,
|
||||
) {
|
||||
// *หมายเหตุ: คุณต้องไปเพิ่ม method update() ใน Service ด้วยถ้าจะใช้ Endpoint นี้
|
||||
// return this.workflowService.update(id, dto);
|
||||
return { message: 'Update logic not implemented yet', id, ...dto };
|
||||
return this.workflowService.update(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,23 @@
|
||||
|
||||
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';
|
||||
import { WorkflowDslService } from './workflow-dsl.service'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
|
||||
import { WorkflowEngineController } from './workflow-engine.controller'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity'; // [New] ต้องสร้างไฟล์นี้ตามแผน Phase 6A
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// เชื่อมต่อกับตาราง workflow_definitions
|
||||
TypeOrmModule.forFeature([WorkflowDefinition]),
|
||||
TypeOrmModule.forFeature([
|
||||
WorkflowDefinition,
|
||||
WorkflowInstance, // [New]
|
||||
WorkflowHistory, // [New]
|
||||
]),
|
||||
],
|
||||
controllers: [WorkflowEngineController], // เพิ่ม Controller สำหรับรับ API
|
||||
providers: [
|
||||
WorkflowEngineService, // Service หลัก
|
||||
WorkflowDslService, // [New] Service สำหรับ Compile/Validate DSL
|
||||
],
|
||||
exports: [WorkflowEngineService], // Export ให้ module อื่นใช้เหมือนเดิม
|
||||
controllers: [WorkflowEngineController],
|
||||
providers: [WorkflowEngineService, WorkflowDslService],
|
||||
exports: [WorkflowEngineService],
|
||||
})
|
||||
export class WorkflowEngineModule {}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
// File: src/modules/workflow-engine/workflow-engine.service.ts
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
// Entities
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowDslService, CompiledWorkflow } from './workflow-dsl.service';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
import {
|
||||
WorkflowInstance,
|
||||
WorkflowStatus,
|
||||
} from './entities/workflow-instance.entity';
|
||||
|
||||
// Services & Interfaces
|
||||
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
||||
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
|
||||
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
|
||||
import { CompiledWorkflow, WorkflowDslService } from './workflow-dsl.service';
|
||||
|
||||
// Interface สำหรับ Backward Compatibility (Logic เดิม)
|
||||
// Legacy Interface (Backward Compatibility)
|
||||
export enum WorkflowAction {
|
||||
APPROVE = 'APPROVE',
|
||||
REJECT = 'REJECT',
|
||||
@@ -35,11 +44,16 @@ export class WorkflowEngineService {
|
||||
constructor(
|
||||
@InjectRepository(WorkflowDefinition)
|
||||
private readonly workflowDefRepo: Repository<WorkflowDefinition>,
|
||||
@InjectRepository(WorkflowInstance)
|
||||
private readonly instanceRepo: Repository<WorkflowInstance>,
|
||||
@InjectRepository(WorkflowHistory)
|
||||
private readonly historyRepo: Repository<WorkflowHistory>,
|
||||
private readonly dslService: WorkflowDslService,
|
||||
private readonly dataSource: DataSource, // ใช้สำหรับ Transaction
|
||||
) {}
|
||||
|
||||
// =================================================================
|
||||
// [NEW] DSL & Workflow Engine (Phase 6A)
|
||||
// [PART 1] Definition Management (Phase 6A)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
@@ -48,8 +62,10 @@ export class WorkflowEngineService {
|
||||
async createDefinition(
|
||||
dto: CreateWorkflowDefinitionDto,
|
||||
): Promise<WorkflowDefinition> {
|
||||
// 1. Compile & Validate DSL
|
||||
const compiled = this.dslService.compile(dto.dsl);
|
||||
|
||||
// 2. Check latest version
|
||||
const latest = await this.workflowDefRepo.findOne({
|
||||
where: { workflow_code: dto.workflow_code },
|
||||
order: { version: 'DESC' },
|
||||
@@ -57,6 +73,7 @@ export class WorkflowEngineService {
|
||||
|
||||
const nextVersion = latest ? latest.version + 1 : 1;
|
||||
|
||||
// 3. Save new version
|
||||
const entity = this.workflowDefRepo.create({
|
||||
workflow_code: dto.workflow_code,
|
||||
version: nextVersion,
|
||||
@@ -65,9 +82,16 @@ export class WorkflowEngineService {
|
||||
is_active: dto.is_active ?? true,
|
||||
});
|
||||
|
||||
return this.workflowDefRepo.save(entity);
|
||||
const saved = await this.workflowDefRepo.save(entity);
|
||||
this.logger.log(
|
||||
`Created Workflow Definition: ${saved.workflow_code} v${saved.version}`,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดต Definition (Re-compile DSL)
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateWorkflowDefinitionDto,
|
||||
@@ -95,33 +119,9 @@ export class WorkflowEngineService {
|
||||
return this.workflowDefRepo.save(definition);
|
||||
}
|
||||
|
||||
async evaluate(dto: EvaluateWorkflowDto): Promise<any> {
|
||||
const definition = await this.workflowDefRepo.findOne({
|
||||
where: { workflow_code: dto.workflow_code, is_active: true },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new NotFoundException(
|
||||
`No active workflow definition found for "${dto.workflow_code}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const compiled: CompiledWorkflow = definition.compiled;
|
||||
const result = this.dslService.evaluate(
|
||||
compiled,
|
||||
dto.current_state,
|
||||
dto.action,
|
||||
dto.context || {},
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Workflow Evaluated: ${dto.workflow_code} [${dto.current_state}] --${dto.action}--> [${result.nextState}]`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Action ที่ทำได้ ณ State ปัจจุบัน
|
||||
*/
|
||||
async getAvailableActions(
|
||||
workflowCode: string,
|
||||
currentState: string,
|
||||
@@ -140,25 +140,206 @@ export class WorkflowEngineService {
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// [LEGACY] Backward Compatibility for Correspondence/RFA Modules
|
||||
// คืนค่า Logic เดิมเพื่อไม่ให้ Module อื่น Error (TS2339)
|
||||
// [PART 2] Runtime Engine (Phase 3.1)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* เริ่มต้น Workflow Instance ใหม่สำหรับเอกสาร
|
||||
*/
|
||||
async createInstance(
|
||||
workflowCode: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
initialContext: Record<string, any> = {},
|
||||
): Promise<WorkflowInstance> {
|
||||
// 1. หา Definition ล่าสุด
|
||||
const definition = await this.workflowDefRepo.findOne({
|
||||
where: { workflow_code: workflowCode, is_active: true },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new NotFoundException(
|
||||
`Workflow "${workflowCode}" not found or inactive.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. หา Initial State จาก Compiled Structure
|
||||
const compiled: CompiledWorkflow = definition.compiled;
|
||||
const initialState = Object.keys(compiled.states).find(
|
||||
(key) => compiled.states[key].initial,
|
||||
);
|
||||
|
||||
if (!initialState) {
|
||||
throw new BadRequestException(
|
||||
`Workflow "${workflowCode}" has no initial state defined.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. สร้าง Instance
|
||||
const instance = this.instanceRepo.create({
|
||||
definition,
|
||||
entityType,
|
||||
entityId,
|
||||
currentState: initialState,
|
||||
status: WorkflowStatus.ACTIVE,
|
||||
context: initialContext,
|
||||
});
|
||||
|
||||
const savedInstance = await this.instanceRepo.save(instance);
|
||||
this.logger.log(
|
||||
`Started Workflow Instance: ${workflowCode} for ${entityType}:${entityId}`,
|
||||
);
|
||||
return savedInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดำเนินการเปลี่ยนสถานะ (Transition) ของ Instance จริงแบบ Transactional
|
||||
*/
|
||||
async processTransition(
|
||||
instanceId: string,
|
||||
action: string,
|
||||
userId: number,
|
||||
comment?: string,
|
||||
payload: Record<string, any> = {},
|
||||
) {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// 1. Lock Instance เพื่อป้องกัน Race Condition (Pessimistic Write Lock)
|
||||
const instance = await queryRunner.manager.findOne(WorkflowInstance, {
|
||||
where: { id: instanceId },
|
||||
relations: ['definition'],
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
throw new NotFoundException(
|
||||
`Workflow Instance "${instanceId}" not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (instance.status !== WorkflowStatus.ACTIVE) {
|
||||
throw new BadRequestException(
|
||||
`Workflow is not active (Status: ${instance.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Evaluate Logic ผ่าน DSL Service
|
||||
const compiled: CompiledWorkflow = instance.definition.compiled;
|
||||
const context = { ...instance.context, userId, ...payload }; // Merge Context
|
||||
|
||||
// * DSL Service จะ throw error ถ้า action ไม่ถูกต้อง หรือสิทธิ์ไม่พอ
|
||||
const evaluation = this.dslService.evaluate(
|
||||
compiled,
|
||||
instance.currentState,
|
||||
action,
|
||||
context,
|
||||
);
|
||||
|
||||
const fromState = instance.currentState;
|
||||
const toState = evaluation.nextState;
|
||||
|
||||
// 3. อัปเดต Instance
|
||||
instance.currentState = toState;
|
||||
instance.context = context; // อัปเดต Context ด้วย
|
||||
|
||||
// เช็คว่าเป็น Terminal State หรือไม่?
|
||||
if (compiled.states[toState].terminal) {
|
||||
instance.status = WorkflowStatus.COMPLETED;
|
||||
}
|
||||
|
||||
await queryRunner.manager.save(instance);
|
||||
|
||||
// 4. บันทึก History (Audit Trail)
|
||||
const history = this.historyRepo.create({
|
||||
instanceId: instance.id,
|
||||
fromState,
|
||||
toState,
|
||||
action,
|
||||
actionByUserId: userId,
|
||||
comment,
|
||||
metadata: {
|
||||
events: evaluation.events,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
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();
|
||||
|
||||
this.logger.log(
|
||||
`Transition: ${instanceId} [${fromState}] --${action}--> [${toState}] by User:${userId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nextState: toState,
|
||||
events: evaluation.events,
|
||||
isCompleted: instance.status === WorkflowStatus.COMPLETED,
|
||||
};
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Transition Failed for ${instanceId}: ${(err as Error).message}`,
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (Utility) Evaluate แบบไม่บันทึกผล (Dry Run) สำหรับ Test หรือ Preview
|
||||
*/
|
||||
async evaluate(dto: EvaluateWorkflowDto): Promise<any> {
|
||||
const definition = await this.workflowDefRepo.findOne({
|
||||
where: { workflow_code: dto.workflow_code, is_active: true },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new NotFoundException(`Workflow "${dto.workflow_code}" not found`);
|
||||
}
|
||||
|
||||
return this.dslService.evaluate(
|
||||
definition.compiled,
|
||||
dto.current_state,
|
||||
dto.action,
|
||||
dto.context || {},
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// [PART 3] Legacy Support (Backward Compatibility)
|
||||
// รักษา Logic เดิมไว้เพื่อให้ Module อื่น (Correspondence/RFA) ทำงานต่อได้
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* คำนวณสถานะถัดไปแบบ Linear Sequence (Logic เดิม)
|
||||
* ใช้สำหรับ CorrespondenceService และ RfaService ที่ยังไม่ได้ Refactor
|
||||
* @deprecated แนะนำให้เปลี่ยนไปใช้ processTransition แทนเมื่อ Refactor เสร็จ
|
||||
*/
|
||||
processAction(
|
||||
currentSequence: number,
|
||||
totalSteps: number,
|
||||
action: string, // รับเป็น string เพื่อความยืดหยุ่น
|
||||
action: string,
|
||||
returnToSequence?: number,
|
||||
): TransitionResult {
|
||||
// Map string action to enum logic
|
||||
switch (action) {
|
||||
case WorkflowAction.APPROVE:
|
||||
case WorkflowAction.ACKNOWLEDGE:
|
||||
case 'APPROVE': // Case sensitive handling fallback
|
||||
case 'APPROVE':
|
||||
case 'ACKNOWLEDGE':
|
||||
if (currentSequence >= totalSteps) {
|
||||
return {
|
||||
@@ -193,7 +374,6 @@ export class WorkflowEngineService {
|
||||
};
|
||||
|
||||
default:
|
||||
// กรณีส่ง Action อื่นมา ให้ถือว่าเป็น Approve (หรือจะ Throw Error ก็ได้)
|
||||
this.logger.warn(
|
||||
`Unknown legacy action: ${action}, treating as next step.`,
|
||||
);
|
||||
|
||||
@@ -22,6 +22,27 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": true, // ห้ามใช้ Any โดยไม่จำเป็น
|
||||
"strictBindCallApply": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@backend": ["./src"],
|
||||
"@backend/*": ["./src/*"],
|
||||
"@modules": ["./src/modules"],
|
||||
"@common": ["./src/common"],
|
||||
"@config": ["./src/common/config"],
|
||||
"@circulation": ["./src/modules/circulation"],
|
||||
"@correspondence": ["./src/modules/correspondence"],
|
||||
"@document-numbering": ["./src/modules/document-numbering"],
|
||||
"@drawing": ["./src/modules/drawing"],
|
||||
"@json-schema": ["./src/modules/json-schema"],
|
||||
"@master": ["./src/modules/master"],
|
||||
"@monitoring": ["./src/modules/monitoring"],
|
||||
"@notification": ["./src/modules/notification"],
|
||||
"@project": ["./src/modules/project"],
|
||||
"@rfa": ["./src/modules/rfa"],
|
||||
"@search": ["./src/modules/search"],
|
||||
"@transmittal": ["./src/modules/transmittal"],
|
||||
"@users": ["./src/modules/users"],
|
||||
"@workflow-engine": ["./src/modules/workflow-engine"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user