251122:1700 Phase 4

This commit is contained in:
admin
2025-11-22 17:21:55 +07:00
parent bf0308e350
commit a3474bff6a
63 changed files with 10062 additions and 109 deletions

View File

@@ -29,6 +29,7 @@
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.3",
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0",
"ajv": "^8.17.1",
@@ -48,6 +49,7 @@
"redlock": "5.0.0-beta.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1",
"typeorm": "^0.3.27",
"uuid": "^13.0.0"
},

66
backend/pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.1
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
'@nestjs/swagger':
specifier: ^11.2.3
version: 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
'@nestjs/throttler':
specifier: ^6.4.0
version: 6.4.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)
@@ -92,6 +95,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@5.1.0)
typeorm:
specifier: ^0.3.27
version: 0.3.27(ioredis@5.8.2)(mysql2@3.15.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
@@ -756,6 +762,9 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
cpu: [arm64]
@@ -887,6 +896,23 @@ packages:
peerDependencies:
typescript: '>=4.8.2'
'@nestjs/swagger@11.2.3':
resolution: {integrity: sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==}
peerDependencies:
'@fastify/static': ^8.0.0
'@nestjs/common': ^11.0.1
'@nestjs/core': ^11.0.1
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
'@fastify/static':
optional: true
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/testing@11.1.9':
resolution: {integrity: sha512-UFxerBDdb0RUNxQNj25pvkvNE7/vxKhXYWBt3QuwBFnYISzRIzhVlyIqLfoV5YI3zV0m0Nn4QAn1KM0zzwfEng==}
peerDependencies:
@@ -948,6 +974,9 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@sinclair/typebox@0.34.41':
resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==}
@@ -3226,6 +3255,15 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
swagger-ui-dist@5.30.2:
resolution: {integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==}
swagger-ui-express@5.0.1:
resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
engines: {node: '>= v0.10.32'}
peerDependencies:
express: '>=4.0.0 || >=5.0.0-beta'
symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}
@@ -4330,6 +4368,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.16.0': {}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
optional: true
@@ -4474,6 +4514,21 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
js-yaml: 4.1.1
lodash: 4.17.21
path-to-regexp: 8.3.0
reflect-metadata: 0.2.2
swagger-ui-dist: 5.30.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.14.2
'@nestjs/testing@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9)':
dependencies:
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -4523,6 +4578,8 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@scarf/scarf@1.4.0': {}
'@sinclair/typebox@0.34.41': {}
'@sinonjs/commons@3.0.1':
@@ -7067,6 +7124,15 @@ snapshots:
dependencies:
has-flag: 4.0.0
swagger-ui-dist@5.30.2:
dependencies:
'@scarf/scarf': 1.4.0
swagger-ui-express@5.0.1(express@5.1.0):
dependencies:
express: 5.1.0
swagger-ui-dist: 5.30.2
symbol-observable@4.0.0: {}
synckit@0.11.11:

View File

@@ -0,0 +1,52 @@
import { DataSource } from 'typeorm';
import * as fs from 'fs';
// Read .env to get DB config
const envFile = fs.readFileSync('.env', 'utf8');
const getEnv = (key: string) => {
const line = envFile.split('\n').find(l => l.startsWith(key + '='));
return line ? line.split('=')[1].trim() : '';
};
const dataSource = new DataSource({
type: 'mariadb',
host: getEnv('DB_HOST') || 'localhost',
port: parseInt(getEnv('DB_PORT') || '3306'),
username: getEnv('DB_USERNAME') || 'admin',
password: getEnv('DB_PASSWORD') || 'Center2025',
database: getEnv('DB_DATABASE') || 'lcbp3_dev',
entities: [],
synchronize: false,
});
async function main() {
await dataSource.initialize();
console.log('Connected to DB');
try {
const assignments = await dataSource.query('SELECT * FROM user_assignments');
console.log('All Assignments:', assignments);
// Check if User 3 has any assignment
const user3Assign = assignments.find((a: any) => a.user_id === 3);
if (!user3Assign) {
console.log('User 3 has NO assignments.');
// Try to insert assignment for User 3 (Editor)
console.log('Inserting assignment for User 3 (Role 4, Org 41)...');
await dataSource.query(`
INSERT INTO user_assignments (user_id, role_id, organization_id, assigned_by_user_id)
VALUES (3, 4, 41, 1)
`);
console.log('Inserted assignment for User 3.');
} else {
console.log('User 3 Assignment:', user3Assign);
}
} catch (err) {
console.error(err);
} finally {
await dataSource.destroy();
}
}
main();

View File

@@ -0,0 +1,126 @@
import * as crypto from 'crypto';
// Configuration
const JWT_SECRET =
'eebc122aa65adde8c76c6a0847d9649b2b67a06db1504693e6c912e51499b76e';
const API_URL = 'http://localhost:3000/api';
// Helper to sign JWT
function signJwt(payload: any) {
const header = { alg: 'HS256', typ: 'JWT' };
const encodedHeader = Buffer.from(JSON.stringify(header)).toString(
'base64url',
);
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
'base64url',
);
const signature = crypto
.createHmac('sha256', JWT_SECRET)
.update(encodedHeader + '.' + encodedPayload)
.digest('base64url');
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
async function main() {
// 1. Generate Token for Editor01 (ID 3)
const token = signJwt({ username: 'editor01', sub: 3 });
console.log('Generated Token:', token);
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
};
try {
// 1.5 Check Permissions
console.log('\nChecking Permissions...');
const permRes = await fetch(`${API_URL}/users/me/permissions`, { headers });
if (permRes.ok) {
const perms = await permRes.json();
console.log('My Permissions:', perms);
} else {
console.error(
'Failed to get permissions:',
permRes.status,
await permRes.text(),
);
}
// 2. Create Correspondence
console.log('\nCreating Correspondence...');
const createRes = await fetch(`${API_URL}/correspondences`, {
method: 'POST',
headers,
body: JSON.stringify({
projectId: 1,
typeId: 1, // Assuming ID 1 exists (e.g., RFA or Memo)
// originatorId: 1, // Removed for Admin user
title: 'Manual Verification Doc',
details: { note: 'Created via script' },
}),
});
if (!createRes.ok) {
throw new Error(
`Create failed: ${createRes.status} ${await createRes.text()}`,
);
}
const doc: any = await createRes.json();
console.log('Created Document:', doc.id, doc.correspondenceNumber);
// 3. Submit Workflow
console.log('\nSubmitting Workflow...');
const submitRes = await fetch(
`${API_URL}/correspondences/${doc.id}/submit`,
{
method: 'POST',
headers,
body: JSON.stringify({
templateId: 1, // Assuming Template ID 1 exists
}),
},
);
if (!submitRes.ok) {
const text = await submitRes.text();
console.error(`Submit failed: ${submitRes.status} ${text}`);
if (text.includes('template')) {
console.warn(
'⚠️ Template ID 1 not found. Please ensure a Routing Template exists.',
);
}
return;
}
console.log('Workflow Submitted Successfully');
// 4. Approve Workflow (as same user for simplicity, assuming logic allows or user has permission)
console.log('\nApproving Workflow...');
const approveRes = await fetch(
`${API_URL}/correspondences/${doc.id}/workflow/action`,
{
method: 'POST',
headers,
body: JSON.stringify({
action: 'APPROVE',
comment: 'Approved via script',
}),
},
);
if (!approveRes.ok) {
throw new Error(
`Approve failed: ${approveRes.status} ${await approveRes.text()}`,
);
}
console.log('Workflow Approved Successfully');
} catch (error: any) {
console.error('Error:', error.message);
}
}
main().catch((err) => console.error(err));

View File

@@ -11,7 +11,7 @@ import { envValidationSchema } from './common/config/env.validation.js'; // ส
// import { CommonModule } from './common/common.module';
import { UserModule } from './modules/user/user.module';
import { ProjectModule } from './modules/project/project.module';
import { FileStorageModule } from './modules/file-storage/file-storage.module';
import { FileStorageModule } from './common/file-storage/file-storage.module.js';
import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module';
import { AuthModule } from './common/auth/auth.module.js'; // <--- เพิ่ม Import นี้ T2.4
import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js';

View File

@@ -5,7 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller.js';
import { UserModule } from '../../modules/user/user.module.js';
import { JwtStrategy } from './jwt.strategy.js';
import { JwtStrategy } from '../guards/jwt.strategy.js';
@Module({
imports: [

View File

@@ -0,0 +1,19 @@
// File: src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Decorator สำหรับดึงข้อมูล User ปัจจุบันจาก Request Object
* ใช้คู่กับ JwtAuthGuard
*
* ตัวอย่างการใช้:
* @Get()
* findAll(@CurrentUser() user: User) { ... }
*/
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
// request.user ถูก set โดย Passport/JwtStrategy
return request.user;
},
);

View File

@@ -6,7 +6,7 @@ import {
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity.js';
import { User } from '../../../modules/user/entities/user.entity.js';
@Entity('attachments')
export class Attachment {

View File

@@ -11,7 +11,7 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileStorageService } from './file-storage.service.js';
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
// ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request
interface RequestWithUser {

View File

@@ -9,18 +9,26 @@ interface JwtPayload {
username: string;
}
import { UserService } from '../../modules/user/user.service.js';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
constructor(
configService: ConfigService,
private userService: UserService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
// ใส่ ! เพื่อยืนยันว่ามีค่าแน่นอน (ConfigValidation เช็คให้แล้ว)
secretOrKey: configService.get<string>('JWT_SECRET')!,
});
}
async validate(payload: JwtPayload) {
return { userId: payload.sub, username: payload.username };
const user = await this.userService.findOne(payload.sub);
if (!user) {
throw new Error('User not found');
}
return user;
}
}

View File

@@ -12,11 +12,15 @@ import { CorrespondenceService } from './correspondence.service.js';
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js';
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto.js'; // <--- ✅ 3. เพิ่ม Import DTO นี้
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js';
import { RbacGuard } from '../../common/auth/rbac.guard.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { WorkflowActionDto } from './dto/workflow-action.dto.js';
// ... imports ...
import { AddReferenceDto } from './dto/add-reference.dto.js';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto.js';
import { Query, Delete } from '@nestjs/common'; // เพิ่ม Query, Delete
@Controller('correspondences')
@UseGuards(JwtAuthGuard, RbacGuard)
export class CorrespondenceController {
@@ -38,10 +42,11 @@ export class CorrespondenceController {
return this.correspondenceService.create(createDto, req.user);
}
// ✅ ปรับปรุง findAll ให้รับ Query Params
@Get()
@RequirePermission('document.view') // 🔒 ต้องมีสิทธิ์ดู
findAll() {
return this.correspondenceService.findAll();
@RequirePermission('document.view')
findAll(@Query() searchDto: SearchCorrespondenceDto) {
return this.correspondenceService.findAll(searchDto);
}
// ✅ เพิ่ม Endpoint นี้ครับ
@@ -58,4 +63,30 @@ export class CorrespondenceController {
req.user,
);
}
// --- REFERENCES ---
@Get(':id/references')
@RequirePermission('document.view')
getReferences(@Param('id', ParseIntPipe) id: number) {
return this.correspondenceService.getReferences(id);
}
@Post(':id/references')
@RequirePermission('document.edit') // ต้องมีสิทธิ์แก้ไขถึงจะเพิ่ม Ref ได้
addReference(
@Param('id', ParseIntPipe) id: number,
@Body() dto: AddReferenceDto,
) {
return this.correspondenceService.addReference(id, dto);
}
@Delete(':id/references/:targetId')
@RequirePermission('document.edit')
removeReference(
@Param('id', ParseIntPipe) id: number,
@Param('targetId', ParseIntPipe) targetId: number,
) {
return this.correspondenceService.removeReference(id, targetId);
}
}

View File

@@ -15,6 +15,8 @@ import { DocumentNumberingModule } from '../document-numbering/document-numberin
import { JsonSchemaModule } from '../json-schema/json-schema.module.js'; // ต้องใช้ Validate Details
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';
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -25,6 +27,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module.
RoutingTemplate, // <--- ลงทะเบียน
RoutingTemplateStep, // <--- ลงทะเบียน
CorrespondenceRouting, // <--- ลงทะเบียน
CorrespondenceReference, // <--- ลงทะเบียน
]),
DocumentNumberingModule, // Import เพื่อขอเลขที่เอกสาร
JsonSchemaModule, // Import เพื่อ Validate JSON

View File

@@ -1,11 +1,15 @@
// File: src/modules/correspondence/correspondence.service.ts
import {
Injectable,
NotFoundException,
BadRequestException,
InternalServerErrorException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Repository, DataSource, Like, In } from 'typeorm';
// Entities
import { Correspondence } from './entities/correspondence.entity.js';
@@ -14,22 +18,28 @@ import { CorrespondenceType } from './entities/correspondence-type.entity.js';
import { CorrespondenceStatus } from './entities/correspondence-status.entity.js';
import { RoutingTemplate } from './entities/routing-template.entity.js';
import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js';
import { CorrespondenceReference } from './entities/correspondence-reference.entity.js'; // Entity สำหรับตารางเชื่อมโยง
import { User } from '../user/entities/user.entity.js';
// DTOs
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js';
import { WorkflowActionDto } from './dto/workflow-action.dto.js';
import { AddReferenceDto } from './dto/add-reference.dto.js';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto.js';
// Interfaces
// Interfaces & Enums
import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface.js';
// Services
import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js';
import { JsonSchemaService } from '../json-schema/json-schema.service.js';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js';
import { UserService } from '../user/user.service.js';
@Injectable()
export class CorrespondenceService {
private readonly logger = new Logger(CorrespondenceService.name);
constructor(
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@@ -43,16 +53,26 @@ export class CorrespondenceService {
private templateRepo: Repository<RoutingTemplate>,
@InjectRepository(CorrespondenceRouting)
private routingRepo: Repository<CorrespondenceRouting>,
@InjectRepository(CorrespondenceReference)
private referenceRepo: Repository<CorrespondenceReference>,
private numberingService: DocumentNumberingService,
private jsonSchemaService: JsonSchemaService,
private workflowEngine: WorkflowEngineService,
private userService: UserService,
private dataSource: DataSource,
) {}
// --- 1. CREATE DOCUMENT ---
/**
* สร้างเอกสารใหม่ (Create Document)
* รองรับ Impersonation Logic: Superadmin สามารถสร้างในนามองค์กรอื่นได้
*
* @param createDto ข้อมูลสำหรับการสร้างเอกสาร
* @param user ผู้ใช้งานที่ทำการสร้าง
* @returns ข้อมูลเอกสารที่สร้างเสร็จแล้ว
*/
async create(createDto: CreateCorrespondenceDto, user: User) {
// 1.1 Validate Basic Info
// 1. ตรวจสอบข้อมูลพื้นฐาน (Basic Validation)
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
@@ -62,59 +82,101 @@ export class CorrespondenceService {
where: { statusCode: 'DRAFT' },
});
if (!statusDraft) {
throw new InternalServerErrorException('Status DRAFT not found');
throw new InternalServerErrorException(
'Status DRAFT not found in Master Data',
);
}
const userOrgId = user.primaryOrganizationId;
// 2. Impersonation Logic & Organization Context
// กำหนด Org เริ่มต้นเป็นของผู้ใช้งานปัจจุบัน
let userOrgId = user.primaryOrganizationId;
// Fallback: หากใน Token ไม่มี Org ID ให้ดึงจาก DB อีกครั้งเพื่อความชัวร์
if (!userOrgId) {
throw new BadRequestException('User must belong to an organization');
const fullUser = await this.userService.findOne(user.user_id);
if (fullUser) {
userOrgId = fullUser.primaryOrganizationId;
}
}
// 1.2 Validate JSON Details
// ตรวจสอบกรณีต้องการสร้างในนามองค์กรอื่น (Impersonation)
if (createDto.originatorId && createDto.originatorId !== userOrgId) {
// ดึง Permissions ของผู้ใช้มาตรวจสอบ
const permissions = await this.userService.getUserPermissions(
user.user_id,
);
// ผู้ใช้ต้องมีสิทธิ์ 'system.manage_all' เท่านั้นจึงจะสวมสิทธิ์ได้
if (!permissions.includes('system.manage_all')) {
throw new ForbiddenException(
'You do not have permission to create documents on behalf of other organizations.',
);
}
// อนุญาตให้ใช้ Org ID ที่ส่งมา
userOrgId = createDto.originatorId;
}
// Final Validation: ต้องมี Org ID เสมอ
if (!userOrgId) {
throw new BadRequestException(
'User must belong to an organization to create documents',
);
}
// 3. Validate JSON Details (ถ้ามี)
if (createDto.details) {
try {
await this.jsonSchemaService.validate(type.typeCode, createDto.details);
} catch (error: any) {
console.warn(`Schema validation warning: ${error.message}`);
// Log warning แต่ไม่ Block การสร้าง (ตามความยืดหยุ่นที่ต้องการ) หรือจะ Throw ก็ได้ตาม Req
this.logger.warn(
`Schema validation warning for ${type.typeCode}: ${error.message}`,
);
}
}
// 4. เริ่ม Transaction (เพื่อความสมบูรณ์ของข้อมูล: เลขที่เอกสาร + ตัวเอกสาร + Revision)
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1.3 Generate Document Number (Double-Lock)
// 4.1 ขอเลขที่เอกสาร (Double-Lock Mechanism ผ่าน NumberingService)
// TODO: Fetch ORG_CODE จาก DB จริงๆ โดยใช้ userOrgId
const orgCode = 'ORG'; // Mock ไว้ก่อน ควร query จาก Organization Entity
const docNumber = await this.numberingService.generateNextNumber(
createDto.projectId,
userOrgId,
userOrgId, // ใช้ ID ของเจ้าของเอกสารจริง (Originator)
createDto.typeId,
new Date().getFullYear(),
{
TYPE_CODE: type.typeCode,
ORG_CODE: 'ORG', // In real app, fetch user's org code
ORG_CODE: orgCode,
},
);
// 1.4 Save Head
// 4.2 สร้าง Correspondence (หัวจดหมาย)
const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber,
correspondenceTypeId: createDto.typeId,
projectId: createDto.projectId,
originatorId: userOrgId,
originatorId: userOrgId, // บันทึก Org ที่ถูกต้อง
isInternal: createDto.isInternal || false,
createdBy: user.user_id,
});
const savedCorr = await queryRunner.manager.save(correspondence);
// 1.5 Save First Revision
// 4.3 สร้าง Revision แรก (Rev 0)
const revision = queryRunner.manager.create(CorrespondenceRevision, {
correspondenceId: savedCorr.id,
revisionNumber: 0,
revisionLabel: 'A',
revisionLabel: 'A', // หรือเริ่มที่ 0 แล้วแต่ Business Logic
isCurrent: true,
statusId: statusDraft.id,
title: createDto.title,
description: createDto.description, // ถ้ามีใน DTO
details: createDto.details,
createdBy: user.user_id,
});
@@ -128,24 +190,70 @@ export class CorrespondenceService {
};
} catch (err) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Failed to create correspondence: ${(err as Error).message}`,
);
throw err;
} finally {
await queryRunner.release();
}
}
// --- READ ---
async findAll() {
return this.correspondenceRepo.find({
relations: ['revisions', 'type', 'project'],
order: { createdAt: 'DESC' },
});
/**
* ค้นหาเอกสาร (Find All)
* รองรับการกรองและค้นหา
*/
async findAll(searchDto: SearchCorrespondenceDto = {}) {
const { search, typeId, projectId, statusId } = searchDto;
const query = this.correspondenceRepo
.createQueryBuilder('corr')
.leftJoinAndSelect('corr.revisions', 'rev')
.leftJoinAndSelect('corr.type', 'type')
.leftJoinAndSelect('corr.project', 'project')
.leftJoinAndSelect('corr.originator', 'org')
.where('rev.isCurrent = :isCurrent', { isCurrent: true }); // ดูเฉพาะ Rev ปัจจุบัน
if (projectId) {
query.andWhere('corr.projectId = :projectId', { projectId });
}
if (typeId) {
query.andWhere('corr.correspondenceTypeId = :typeId', { typeId });
}
if (statusId) {
query.andWhere('rev.statusId = :statusId', { statusId });
}
if (search) {
query.andWhere(
'(corr.correspondenceNumber LIKE :search OR rev.title LIKE :search)',
{ search: `%${search}%` },
);
}
query.orderBy('corr.createdAt', 'DESC');
return query.getMany();
}
/**
* ดึงข้อมูลเอกสารรายตัว (Find One)
* พร้อม Relations ที่จำเป็น
*/
async findOne(id: number) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id },
relations: ['revisions', 'type', 'project'],
relations: [
'revisions',
'revisions.status', // สถานะของ Revision
'type',
'project',
'originator',
// 'tags', // ถ้ามี Relation
// 'attachments' // ถ้ามี Relation ผ่าน Junction
],
});
if (!correspondence) {
@@ -154,9 +262,11 @@ export class CorrespondenceService {
return correspondence;
}
// --- 2. SUBMIT WORKFLOW ---
/**
* ส่งเอกสารเข้า Workflow (Submit)
* สร้าง Routing เริ่มต้นตาม Template
*/
async submit(correspondenceId: number, templateId: number, user: User) {
// 2.1 Get Document & Current Revision
const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId },
relations: ['revisions'],
@@ -171,7 +281,9 @@ export class CorrespondenceService {
throw new NotFoundException('Current revision not found');
}
// 2.2 Get Template Config
// ตรวจสอบสถานะปัจจุบัน (ต้องเป็น DRAFT หรือสถานะที่แก้ได้)
// TODO: เพิ่ม Logic ตรวจสอบ Status ID ว่าเป็น DRAFT หรือไม่
const template = await this.templateRepo.findOne({
where: { id: templateId },
relations: ['steps'],
@@ -179,7 +291,9 @@ export class CorrespondenceService {
});
if (!template || !template.steps?.length) {
throw new BadRequestException('Invalid routing template');
throw new BadRequestException(
'Invalid routing template or no steps defined',
);
}
const queryRunner = this.dataSource.createQueryRunner();
@@ -189,23 +303,25 @@ export class CorrespondenceService {
try {
const firstStep = template.steps[0];
// 2.3 Create First Routing Record
// สร้าง Routing Record แรก
const routing = queryRunner.manager.create(CorrespondenceRouting, {
correspondenceId: currentRevision.id,
templateId: template.id, // ✅ Save templateId for reference
correspondenceId: currentRevision.id, // ผูกกับ Revision
templateId: template.id, // บันทึก templateId ไว้ใช้อ้างอิง
sequence: 1,
fromOrganizationId: user.primaryOrganizationId,
toOrganizationId: firstStep.toOrganizationId,
fromOrganizationId: user.primaryOrganizationId, // ส่งจากเรา
toOrganizationId: firstStep.toOrganizationId, // ไปยังผู้รับคนแรก
stepPurpose: firstStep.stepPurpose,
status: 'SENT',
dueDate: new Date(
Date.now() + (firstStep.expectedDays || 7) * 24 * 60 * 60 * 1000,
),
processedByUserId: user.user_id,
processedByUserId: user.user_id, // บันทึกว่าใครกดส่ง
processedAt: new Date(),
});
await queryRunner.manager.save(routing);
// TODO: อัปเดตสถานะเอกสารเป็น SUBMITTED (เปลี่ยน statusId ใน Revision)
await queryRunner.commitTransaction();
return routing;
} catch (err) {
@@ -216,14 +332,15 @@ export class CorrespondenceService {
}
}
// --- 3. PROCESS ACTION (Approve/Reject/Return) ---
/**
* ประมวลผล Action ใน Workflow (Approve/Reject/Etc.)
*/
async processAction(
correspondenceId: number,
dto: WorkflowActionDto,
user: User,
) {
// 3.1 Find Active Routing Step
// Find correspondence first to ensure it exists
// 1. Find Document & Current Revision
const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId },
relations: ['revisions'],
@@ -236,35 +353,33 @@ export class CorrespondenceService {
if (!currentRevision)
throw new NotFoundException('Current revision not found');
// Find the latest routing step
// 2. Find Active Routing Step (Status = SENT)
// หาสเต็ปล่าสุดที่ส่งมาถึง Org ของเรา และสถานะเป็น SENT
const currentRouting = await this.routingRepo.findOne({
where: {
correspondenceId: currentRevision.id,
// In real scenario, we might check status 'SENT' or 'RECEIVED'
status: 'SENT',
},
order: { sequence: 'DESC' },
relations: ['toOrganization'],
});
if (
!currentRouting ||
currentRouting.status === 'ACTIONED' ||
currentRouting.status === 'REJECTED'
) {
if (!currentRouting) {
throw new BadRequestException(
'No active workflow step found or step already processed',
'No active workflow step found for this document',
);
}
// 3.2 Check Permissions
// User must belong to the target organization of the current step
// 3. Check Permissions (Must be in target Org)
// Logic: ผู้กด Action ต้องสังกัด Org ที่เป็นปลายทางของ Routing นี้
// TODO: เพิ่ม Logic ให้ Superadmin หรือ Document Control กดแทนได้
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
throw new BadRequestException(
'You are not authorized to process this step',
);
}
// 3.3 Load Template to find Next Step Config
// 4. Load Template to find Next Step Config
if (!currentRouting.templateId) {
throw new InternalServerErrorException(
'Routing record missing templateId',
@@ -283,7 +398,7 @@ export class CorrespondenceService {
const totalSteps = template.steps.length;
const currentSeq = currentRouting.sequence;
// 3.4 Calculate Next State using Workflow Engine
// 5. Calculate Next State using Workflow Engine Service
const result = this.workflowEngine.processAction(
currentSeq,
totalSteps,
@@ -291,12 +406,13 @@ export class CorrespondenceService {
dto.returnToSequence,
);
// 6. Execute Database Updates
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 3.5 Update Current Step
// 6.1 Update Current Step
currentRouting.status =
dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
currentRouting.processedByUserId = user.user_id;
@@ -305,39 +421,43 @@ export class CorrespondenceService {
await queryRunner.manager.save(currentRouting);
// 3.6 Create Next Step (If exists and not rejected)
// 6.2 Create Next Step (If exists and not rejected/completed)
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
// ✅ Find config for next step from Template
// ค้นหา Config ของ Step ถัดไปจาก Template
const nextStepConfig = template.steps.find(
(s) => s.sequence === result.nextStepSequence,
);
if (!nextStepConfig) {
throw new InternalServerErrorException(
`Configuration for step ${result.nextStepSequence} not found`,
// อาจจะเป็นกรณี End of Workflow หรือ Logic Error
this.logger.warn(
`Next step ${result.nextStepSequence} not found in template`,
);
} else {
const nextRouting = queryRunner.manager.create(
CorrespondenceRouting,
{
correspondenceId: currentRevision.id,
templateId: template.id,
sequence: result.nextStepSequence,
fromOrganizationId: user.primaryOrganizationId, // ส่งจากคนปัจจุบัน
toOrganizationId: nextStepConfig.toOrganizationId, // ไปยังคนถัดไปตาม Template
stepPurpose: nextStepConfig.stepPurpose,
status: 'SENT',
dueDate: new Date(
Date.now() +
(nextStepConfig.expectedDays || 7) * 24 * 60 * 60 * 1000,
),
},
);
await queryRunner.manager.save(nextRouting);
}
const nextRouting = queryRunner.manager.create(CorrespondenceRouting, {
correspondenceId: currentRevision.id,
templateId: template.id,
sequence: result.nextStepSequence,
fromOrganizationId: user.primaryOrganizationId, // Forwarded by current user
toOrganizationId: nextStepConfig.toOrganizationId, // ✅ Real Target from Template
stepPurpose: nextStepConfig.stepPurpose, // ✅ Real Purpose from Template
status: 'SENT',
dueDate: new Date(
Date.now() +
(nextStepConfig.expectedDays || 7) * 24 * 60 * 60 * 1000,
),
});
await queryRunner.manager.save(nextRouting);
}
// 3.7 Update Document Status (Optional - if Engine suggests)
// 6.3 Update Document Status (Optional / Based on result)
if (result.shouldUpdateStatus) {
// Example: Update revision status to APPROVED or REJECTED
// await this.updateDocumentStatus(currentRevision, result.documentStatus);
// Logic เปลี่ยนสถานะ revision เช่นจาก SUBMITTED -> APPROVED
// await this.updateDocumentStatus(currentRevision, result.documentStatus, queryRunner);
}
await queryRunner.commitTransaction();
@@ -349,4 +469,81 @@ export class CorrespondenceService {
await queryRunner.release();
}
}
// --- REFERENCE MANAGEMENT ---
/**
* เพิ่มเอกสารอ้างอิง (Add Reference)
* ตรวจสอบ Circular Reference และ Duplicate
*/
async addReference(id: number, dto: AddReferenceDto) {
// 1. เช็คว่าเอกสารทั้งคู่มีอยู่จริง
const source = await this.correspondenceRepo.findOne({ where: { id } });
const target = await this.correspondenceRepo.findOne({
where: { id: dto.targetId },
});
if (!source || !target) {
throw new NotFoundException('Source or Target correspondence not found');
}
// 2. ป้องกันการอ้างอิงตัวเอง (Self-Reference)
if (source.id === target.id) {
throw new BadRequestException('Cannot reference self');
}
// 3. ตรวจสอบว่ามีอยู่แล้วหรือไม่ (Duplicate Check)
const exists = await this.referenceRepo.findOne({
where: {
sourceId: id,
targetId: dto.targetId,
},
});
if (exists) {
return exists; // ถ้ามีแล้วก็คืนตัวเดิมไป (Idempotency)
}
// 4. สร้าง Reference
const ref = this.referenceRepo.create({
sourceId: id,
targetId: dto.targetId,
});
return this.referenceRepo.save(ref);
}
/**
* ลบเอกสารอ้างอิง (Remove Reference)
*/
async removeReference(id: number, targetId: number) {
const result = await this.referenceRepo.delete({
sourceId: id,
targetId: targetId,
});
if (result.affected === 0) {
throw new NotFoundException('Reference not found');
}
}
/**
* ดึงรายการเอกสารอ้างอิง (Get References)
* ทั้งที่อ้างถึง (Outgoing) และถูกอ้างถึง (Incoming)
*/
async getReferences(id: number) {
// ดึงรายการที่เอกสารนี้ไปอ้างถึง (Outgoing: This -> Others)
const outgoing = await this.referenceRepo.find({
where: { sourceId: id },
relations: ['target', 'target.type'], // Join เพื่อเอาข้อมูลเอกสารปลายทาง
});
// ดึงรายการที่มาอ้างถึงเอกสารนี้ (Incoming: Others -> This)
const incoming = await this.referenceRepo.find({
where: { targetId: id },
relations: ['source', 'source.type'], // Join เพื่อเอาข้อมูลเอกสารต้นทาง
});
return { outgoing, incoming };
}
}

View File

@@ -0,0 +1,7 @@
import { IsInt, IsNotEmpty } from 'class-validator';
export class AddReferenceDto {
@IsInt()
@IsNotEmpty()
targetId!: number;
}

View File

@@ -20,6 +20,10 @@ export class CreateCorrespondenceDto {
@IsNotEmpty()
title!: string;
@IsString()
@IsOptional()
description?: string;
@IsObject()
@IsOptional()
details?: Record<string, any>; // ข้อมูล JSON (เช่น RFI question)
@@ -28,6 +32,11 @@ export class CreateCorrespondenceDto {
@IsOptional()
isInternal?: boolean;
// ✅ เพิ่ม Field สำหรับ Impersonation (เลือกองค์กรผู้ส่ง)
@IsInt()
@IsOptional()
originatorId?: number;
// (Optional) ถ้าจะมีการแนบไฟล์มาด้วยเลย
// @IsArray()
// @IsString({ each: true })

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsInt } from 'class-validator';
import { Type } from 'class-transformer'; // <--- ✅ Import จาก class-transformer
export class SearchCorrespondenceDto {
@IsOptional()
@IsString()
search?: string; // ค้นหาจาก Title หรือ Number
@IsOptional()
@Type(() => Number)
@IsInt()
typeId?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
projectId?: number;
// status อาจจะซับซ้อนหน่อยเพราะอยู่ที่ Revision แต่ใส่ไว้ก่อน
@IsOptional()
@Type(() => Number)
@IsInt()
statusId?: number;
}

View File

@@ -0,0 +1,19 @@
import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Correspondence } from './correspondence.entity.js';
@Entity('correspondence_references')
export class CorrespondenceReference {
@PrimaryColumn({ name: 'src_correspondence_id' })
sourceId!: number;
@PrimaryColumn({ name: 'tgt_correspondence_id' })
targetId!: number;
@ManyToOne(() => Correspondence, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'src_correspondence_id' })
source?: Correspondence;
@ManyToOne(() => Correspondence, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tgt_correspondence_id' })
target?: Correspondence;
}

View File

@@ -0,0 +1,76 @@
import {
Controller,
Get,
Post,
Body,
Param,
Delete,
Put,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ContractDrawingService } from './contract-drawing.service';
import { CreateContractDrawingDto } from './dto/create-contract-drawing.dto';
import { UpdateContractDrawingDto } from './dto/update-contract-drawing.dto';
import { SearchContractDrawingDto } from './dto/search-contract-drawing.dto';
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 { User } from '../user/entities/user.entity';
@ApiTags('Contract Drawings')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('drawings/contract')
export class ContractDrawingController {
constructor(
private readonly contractDrawingService: ContractDrawingService,
) {}
@Post()
@ApiOperation({ summary: 'Create new Contract Drawing' })
@RequirePermission('drawing.create') // สิทธิ์ ID 39: สร้าง/แก้ไขข้อมูลแบบ
create(
@Body() createDto: CreateContractDrawingDto,
@CurrentUser() user: User,
) {
return this.contractDrawingService.create(createDto, user);
}
@Get()
@ApiOperation({ summary: 'Search Contract Drawings' })
@RequirePermission('document.view') // สิทธิ์ ID 31: ดูเอกสารทั่วไป
findAll(@Query() searchDto: SearchContractDrawingDto) {
return this.contractDrawingService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get Contract Drawing details' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.contractDrawingService.findOne(id);
}
@Put(':id')
@ApiOperation({ summary: 'Update Contract Drawing' })
@RequirePermission('drawing.create') // สิทธิ์ ID 39 ครอบคลุมการแก้ไขด้วย
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateContractDrawingDto,
@CurrentUser() user: User,
) {
return this.contractDrawingService.update(id, updateDto, user);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete Contract Drawing (Soft Delete)' })
@RequirePermission('document.delete') // สิทธิ์ ID 34: ลบเอกสาร
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) {
return this.contractDrawingService.remove(id, user);
}
}

View File

@@ -0,0 +1,248 @@
import {
Injectable,
NotFoundException,
ConflictException,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In, Brackets } from 'typeorm';
// Entities
import { ContractDrawing } from './entities/contract-drawing.entity';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { User } from '../user/entities/user.entity';
// DTOs
import { CreateContractDrawingDto } from './dto/create-contract-drawing.dto';
import { SearchContractDrawingDto } from './dto/search-contract-drawing.dto';
import { UpdateContractDrawingDto } from './dto/update-contract-drawing.dto';
// Services
import { FileStorageService } from '../../common/file-storage/file-storage.service';
@Injectable()
export class ContractDrawingService {
private readonly logger = new Logger(ContractDrawingService.name);
constructor(
@InjectRepository(ContractDrawing)
private drawingRepo: Repository<ContractDrawing>,
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private fileStorageService: FileStorageService,
private dataSource: DataSource,
) {}
/**
* สร้างแบบสัญญาใหม่ (Create Contract Drawing)
* - ตรวจสอบเลขที่ซ้ำในโปรเจกต์
* - บันทึกข้อมูล
* - ผูกไฟล์แนบและ Commit ไฟล์จาก Temp -> Permanent
*/
async create(createDto: CreateContractDrawingDto, user: User) {
// 1. ตรวจสอบเลขที่แบบซ้ำ (Unique per Project)
const exists = await this.drawingRepo.findOne({
where: {
projectId: createDto.projectId,
contractDrawingNo: createDto.contractDrawingNo,
},
});
if (exists) {
throw new ConflictException(
`Contract Drawing No. "${createDto.contractDrawingNo}" already exists in this project.`,
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 2. เตรียมไฟล์แนบ
let attachments: Attachment[] = [];
if (createDto.attachmentIds?.length) {
attachments = await this.attachmentRepo.findBy({
id: In(createDto.attachmentIds),
});
}
// 3. สร้าง Entity
const drawing = queryRunner.manager.create(ContractDrawing, {
projectId: createDto.projectId,
contractDrawingNo: createDto.contractDrawingNo,
title: createDto.title,
subCategoryId: createDto.subCategoryId,
volumeId: createDto.volumeId,
updatedBy: user.user_id,
attachments: attachments,
});
const savedDrawing = await queryRunner.manager.save(drawing);
// 4. Commit Files (ย้ายไฟล์จริง)
if (createDto.attachmentIds?.length) {
// ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง
await this.fileStorageService.commit(
createDto.attachmentIds.map(String),
);
}
await queryRunner.commitTransaction();
return savedDrawing;
} catch (err) {
await queryRunner.rollbackTransaction();
// ✅ FIX TS18046: Cast err เป็น Error
this.logger.error(
`Failed to create contract drawing: ${(err as Error).message}`,
);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* ค้นหาแบบสัญญา (Search & Filter)
* รองรับ Pagination และการค้นหาด้วย Text
*/
async findAll(searchDto: SearchContractDrawingDto) {
const {
projectId,
volumeId,
subCategoryId,
search,
page = 1,
pageSize = 20,
} = searchDto;
const query = this.drawingRepo
.createQueryBuilder('drawing')
.leftJoinAndSelect('drawing.attachments', 'files')
// .leftJoinAndSelect('drawing.subCategory', 'subCat')
// .leftJoinAndSelect('drawing.volume', 'vol')
.where('drawing.projectId = :projectId', { projectId });
// Filter by Volume
if (volumeId) {
query.andWhere('drawing.volumeId = :volumeId', { volumeId });
}
// Filter by SubCategory
if (subCategoryId) {
query.andWhere('drawing.subCategoryId = :subCategoryId', {
subCategoryId,
});
}
// Search Text (No. or Title)
if (search) {
query.andWhere(
new Brackets((qb) => {
qb.where('drawing.contractDrawingNo LIKE :search', {
search: `%${search}%`,
}).orWhere('drawing.title LIKE :search', { search: `%${search}%` });
}),
);
}
query.orderBy('drawing.contractDrawingNo', 'ASC');
const skip = (page - 1) * pageSize;
query.skip(skip).take(pageSize);
const [items, total] = await query.getManyAndCount();
return {
data: items,
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
};
}
/**
* ดึงข้อมูลแบบรายตัว (Get One)
*/
async findOne(id: number) {
const drawing = await this.drawingRepo.findOne({
where: { id },
relations: ['attachments'], // เพิ่ม relations อื่นๆ ตามต้องการ
});
if (!drawing) {
throw new NotFoundException(`Contract Drawing ID ${id} not found`);
}
return drawing;
}
/**
* แก้ไขข้อมูลแบบ (Update)
*/
async update(id: number, updateDto: UpdateContractDrawingDto, user: User) {
const drawing = await this.findOne(id);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Update Fields
if (updateDto.contractDrawingNo)
drawing.contractDrawingNo = updateDto.contractDrawingNo;
if (updateDto.title) drawing.title = updateDto.title;
if (updateDto.volumeId !== undefined)
drawing.volumeId = updateDto.volumeId;
if (updateDto.subCategoryId !== undefined)
drawing.subCategoryId = updateDto.subCategoryId;
drawing.updatedBy = user.user_id;
// Update Attachments (Replace logic)
if (updateDto.attachmentIds) {
const newAttachments = await this.attachmentRepo.findBy({
id: In(updateDto.attachmentIds),
});
drawing.attachments = newAttachments;
// Commit new files
// ✅ FIX TS2345: แปลง number[] เป็น string[] ก่อนส่ง
await this.fileStorageService.commit(
updateDto.attachmentIds.map(String),
);
}
const updatedDrawing = await queryRunner.manager.save(drawing);
await queryRunner.commitTransaction();
return updatedDrawing;
} catch (err) {
await queryRunner.rollbackTransaction();
// ✅ FIX TS18046: Cast err เป็น Error (Optional: Added logger here too for consistency)
this.logger.error(
`Failed to update contract drawing: ${(err as Error).message}`,
);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* ลบแบบสัญญา (Soft Delete)
*/
async remove(id: number, user: User) {
const drawing = await this.findOne(id);
// บันทึกว่าใครเป็นคนลบก่อน Soft Delete (Optional)
drawing.updatedBy = user.user_id;
await this.drawingRepo.save(drawing);
return this.drawingRepo.softRemove(drawing);
}
}

View File

@@ -0,0 +1,71 @@
import {
Controller,
Get,
Post,
Body,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DrawingMasterDataService } from './drawing-master-data.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Drawing Master Data')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('drawings/master')
export class DrawingMasterDataController {
// ✅ ต้องมี export ตรงนี้
constructor(private readonly masterDataService: DrawingMasterDataService) {}
// --- Contract Drawing Endpoints ---
@Get('contract/volumes')
@ApiOperation({ summary: 'List Contract Drawing Volumes' })
@RequirePermission('document.view')
getVolumes(@Query('projectId', ParseIntPipe) projectId: number) {
return this.masterDataService.findAllVolumes(projectId);
}
@Post('contract/volumes')
@ApiOperation({ summary: 'Create Volume (Admin/PM)' })
@RequirePermission('master_data.drawing_category.manage') // สิทธิ์ ID 16
createVolume(@Body() body: any) {
// ควรใช้ DTO จริงในการผลิต
return this.masterDataService.createVolume(body);
}
@Get('contract/sub-categories')
@ApiOperation({ summary: 'List Contract Drawing Sub-Categories' })
@RequirePermission('document.view')
getContractSubCats(@Query('projectId', ParseIntPipe) projectId: number) {
return this.masterDataService.findAllContractSubCats(projectId);
}
@Post('contract/sub-categories')
@ApiOperation({ summary: 'Create Contract Sub-Category (Admin/PM)' })
@RequirePermission('master_data.drawing_category.manage')
createContractSubCat(@Body() body: any) {
return this.masterDataService.createContractSubCat(body);
}
// --- Shop Drawing Endpoints ---
@Get('shop/main-categories')
@ApiOperation({ summary: 'List Shop Drawing Main Categories' })
@RequirePermission('document.view')
getShopMainCats() {
return this.masterDataService.findAllShopMainCats();
}
@Get('shop/sub-categories')
@ApiOperation({ summary: 'List Shop Drawing Sub-Categories' })
@RequirePermission('document.view')
getShopSubCats(@Query('mainCategoryId') mainCategoryId?: number) {
return this.masterDataService.findAllShopSubCats(mainCategoryId);
}
}

View File

@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
// Entities
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-category.entity';
import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
@Injectable()
export class DrawingMasterDataService {
constructor(
@InjectRepository(ContractDrawingVolume)
private cdVolumeRepo: Repository<ContractDrawingVolume>,
@InjectRepository(ContractDrawingSubCategory)
private cdSubCatRepo: Repository<ContractDrawingSubCategory>,
@InjectRepository(ShopDrawingMainCategory)
private sdMainCatRepo: Repository<ShopDrawingMainCategory>,
@InjectRepository(ShopDrawingSubCategory)
private sdSubCatRepo: Repository<ShopDrawingSubCategory>,
) {}
// --- Contract Drawing Volumes ---
async findAllVolumes(projectId: number) {
return this.cdVolumeRepo.find({
where: { projectId },
order: { sortOrder: 'ASC' },
});
}
async createVolume(data: Partial<ContractDrawingVolume>) {
const volume = this.cdVolumeRepo.create(data);
return this.cdVolumeRepo.save(volume);
}
// --- Contract Drawing Sub-Categories ---
async findAllContractSubCats(projectId: number) {
return this.cdSubCatRepo.find({
where: { projectId },
order: { sortOrder: 'ASC' },
});
}
async createContractSubCat(data: Partial<ContractDrawingSubCategory>) {
const subCat = this.cdSubCatRepo.create(data);
return this.cdSubCatRepo.save(subCat);
}
// --- Shop Drawing Main Categories ---
async findAllShopMainCats() {
return this.sdMainCatRepo.find({
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
// --- Shop Drawing Sub Categories ---
async findAllShopSubCats(mainCategoryId?: number) {
// ✅ FIX: ใช้วิธี Spread Operator เพื่อสร้าง Object เงื่อนไขที่ถูกต้องตาม Type
const where: FindOptionsWhere<ShopDrawingSubCategory> = {
isActive: true,
...(mainCategoryId ? { mainCategoryId } : {}),
};
return this.sdSubCatRepo.find({
where,
order: { sortOrder: 'ASC' },
relations: ['mainCategory'], // Load Parent Info
});
}
}

View File

@@ -0,0 +1,63 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// Entities (Main)
import { ContractDrawing } from './entities/contract-drawing.entity';
import { ShopDrawing } from './entities/shop-drawing.entity';
import { ShopDrawingRevision } from './entities/shop-drawing-revision.entity';
// Entities (Master Data - Contract Drawing)
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-category.entity';
// Entities (Master Data - Shop Drawing) - ✅ เพิ่มใหม่
import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
// Common Entities
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
// Services
import { ShopDrawingService } from './shop-drawing.service';
import { ContractDrawingService } from './contract-drawing.service';
import { DrawingMasterDataService } from './drawing-master-data.service'; // ✅ New
// Controllers
import { ShopDrawingController } from './shop-drawing.controller';
import { ContractDrawingController } from './contract-drawing.controller';
import { DrawingMasterDataController } from './drawing-master-data.controller';
// Modules
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([
// Main
ContractDrawing,
ShopDrawing,
ShopDrawingRevision,
// Master Data
ContractDrawingVolume,
ContractDrawingSubCategory,
ShopDrawingMainCategory, // ✅
ShopDrawingSubCategory, // ✅
// Common
Attachment,
]),
FileStorageModule,
],
providers: [
ShopDrawingService,
ContractDrawingService,
DrawingMasterDataService,
],
controllers: [
ShopDrawingController,
ContractDrawingController,
DrawingMasterDataController,
],
exports: [ShopDrawingService, ContractDrawingService],
})
export class DrawingModule {}

View File

@@ -0,0 +1,34 @@
import {
IsString,
IsInt,
IsOptional,
IsArray,
IsNotEmpty,
} from 'class-validator';
export class CreateContractDrawingDto {
@IsInt()
@IsNotEmpty()
projectId!: number; // ✅ ใส่ !
@IsString()
@IsNotEmpty()
contractDrawingNo!: string; // ✅ ใส่ !
@IsString()
@IsNotEmpty()
title!: string; // ✅ ใส่ !
@IsInt()
@IsOptional()
subCategoryId?: number; // ✅ ใส่ ?
@IsInt()
@IsOptional()
volumeId?: number; // ✅ ใส่ ?
@IsArray()
@IsInt({ each: true })
@IsOptional()
attachmentIds?: number[]; // ✅ ใส่ ?
}

View File

@@ -0,0 +1,30 @@
import {
IsString,
IsOptional,
IsDateString,
IsArray,
IsInt,
} from 'class-validator';
export class CreateShopDrawingRevisionDto {
@IsString()
revisionLabel!: string; // จำเป็น: ใส่ !
@IsDateString()
@IsOptional()
revisionDate?: string; // Optional: ใส่ ?
@IsString()
@IsOptional()
description?: string; // Optional: ใส่ ?
@IsArray()
@IsInt({ each: true })
@IsOptional()
contractDrawingIds?: number[]; // Optional: ใส่ ?
@IsArray()
@IsInt({ each: true })
@IsOptional()
attachmentIds?: number[]; // Optional: ใส่ ?
}

View File

@@ -0,0 +1,47 @@
import {
IsString,
IsInt,
IsOptional,
IsDateString,
IsArray,
} from 'class-validator';
export class CreateShopDrawingDto {
@IsInt()
projectId!: number; // !
@IsString()
drawingNumber!: string; // !
@IsString()
title!: string; // !
@IsInt()
mainCategoryId!: number; // !
@IsInt()
subCategoryId!: number; // !
// First Revision Data (Optional ทั้งหมด เพราะถ้าไม่ส่งมาจะ Default ให้)
@IsString()
@IsOptional()
revisionLabel?: string; // ?
@IsDateString()
@IsOptional()
revisionDate?: string; // ?
@IsString()
@IsOptional()
description?: string; // ?
@IsArray()
@IsInt({ each: true })
@IsOptional()
contractDrawingIds?: number[]; // ?
@IsArray()
@IsInt({ each: true })
@IsOptional()
attachmentIds?: number[]; // ?
}

View File

@@ -0,0 +1,33 @@
import { IsInt, IsOptional, IsString, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';
export class SearchContractDrawingDto {
@IsInt()
@Type(() => Number)
@IsNotEmpty()
projectId!: number; // จำเป็น: ใส่ !
@IsOptional()
@IsInt()
@Type(() => Number)
volumeId?: number; // Optional: ใส่ ?
@IsOptional()
@IsInt()
@Type(() => Number)
subCategoryId?: number; // Optional: ใส่ ?
@IsOptional()
@IsString()
search?: string; // Optional: ใส่ ?
@IsOptional()
@IsInt()
@Type(() => Number)
page: number = 1; // มีค่า Default ไม่ต้องใส่ ! หรือ ?
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize: number = 20; // มีค่า Default ไม่ต้องใส่ ! หรือ ?
}

View File

@@ -0,0 +1,32 @@
import { IsInt, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';
export class SearchShopDrawingDto {
@IsInt()
@Type(() => Number)
projectId!: number; // จำเป็น: ใส่ !
@IsOptional()
@IsInt()
@Type(() => Number)
mainCategoryId?: number; // Optional: ใส่ ?
@IsOptional()
@IsInt()
@Type(() => Number)
subCategoryId?: number; // Optional: ใส่ ?
@IsOptional()
@IsString()
search?: string; // Optional: ใส่ ?
@IsOptional()
@IsInt()
@Type(() => Number)
page: number = 1; // มีค่า Default
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize: number = 20; // มีค่า Default
}

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContractDrawingDto } from './create-contract-drawing.dto';
export class UpdateContractDrawingDto extends PartialType(
CreateContractDrawingDto,
) {}

View File

@@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';
@Entity('contract_drawing_sub_cats')
export class ContractDrawingSubCategory {
@PrimaryGeneratedColumn()
id!: number; // เติม !
@Column({ name: 'project_id' })
projectId!: number; // เติม !
@Column({ name: 'sub_cat_code', length: 50 })
subCatCode!: string; // เติม !
@Column({ name: 'sub_cat_name', length: 255 })
subCatName!: string; // เติม !
@Column({ type: 'text', nullable: true })
description?: string; // Nullable ใช้ ?
@Column({ name: 'sort_order', default: 0 })
sortOrder!: number; // เติม !
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // เติม !
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date; // เติม !
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project!: Project; // เติม !
}

View File

@@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';
@Entity('contract_drawing_volumes')
export class ContractDrawingVolume {
@PrimaryGeneratedColumn()
id!: number; // เติม !
@Column({ name: 'project_id' })
projectId!: number; // เติม !
@Column({ name: 'volume_code', length: 50 })
volumeCode!: string; // เติม !
@Column({ name: 'volume_name', length: 255 })
volumeName!: string; // เติม !
@Column({ type: 'text', nullable: true })
description?: string; // Nullable ใช้ ?
@Column({ name: 'sort_order', default: 0 })
sortOrder!: number; // เติม !
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // เติม !
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date; // เติม !
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project!: Project; // เติม ! (ตัวที่ Error)
}

View File

@@ -0,0 +1,76 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
ManyToOne,
JoinColumn,
ManyToMany,
JoinTable,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';
import { User } from '../../user/entities/user.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import { ContractDrawingSubCategory } from './contract-drawing-sub-category.entity';
import { ContractDrawingVolume } from './contract-drawing-volume.entity';
@Entity('contract_drawings')
export class ContractDrawing {
@PrimaryGeneratedColumn()
id!: number; // ! ห้ามว่าง
@Column({ name: 'project_id' })
projectId!: number; // ! ห้ามว่าง
@Column({ name: 'condwg_no', length: 255 })
contractDrawingNo!: string; // ! ห้ามว่าง
@Column({ length: 255 })
title!: string; // ! ห้ามว่าง
@Column({ name: 'sub_cat_id', nullable: true })
subCategoryId?: number; // ? ว่างได้ (Nullable)
@Column({ name: 'volume_id', nullable: true })
volumeId?: number; // ? ว่างได้ (Nullable)
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // ! ห้ามว่าง
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date; // ! ห้ามว่าง
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date; // ? ว่างได้ (Nullable)
@Column({ name: 'updated_by', nullable: true })
updatedBy?: number; // ? ว่างได้ (Nullable)
// --- Relations ---
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project!: Project; // ! ห้ามว่าง
@ManyToOne(() => User)
@JoinColumn({ name: 'updated_by' })
updater?: User; // ? ว่างได้
@ManyToOne(() => ContractDrawingSubCategory)
@JoinColumn({ name: 'sub_cat_id' })
subCategory?: ContractDrawingSubCategory; // ? ว่างได้ (สัมพันธ์กับ subCategoryId)
@ManyToOne(() => ContractDrawingVolume)
@JoinColumn({ name: 'volume_id' })
volume?: ContractDrawingVolume; // ? แก้ไขตรงนี้: ใส่ ? เพราะ volumeId เป็น Nullable
@ManyToMany(() => Attachment)
@JoinTable({
name: 'contract_drawing_attachments',
joinColumn: { name: 'contract_drawing_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'attachment_id', referencedColumnName: 'id' },
})
attachments!: Attachment[]; // ! ห้ามว่าง (TypeORM จะ return [] ถ้าไม่มี)
}

View File

@@ -0,0 +1,34 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('shop_drawing_main_categories')
export class ShopDrawingMainCategory {
@PrimaryGeneratedColumn()
id!: number; // เติม !
@Column({ name: 'main_category_code', length: 50, unique: true })
mainCategoryCode!: string; // เติม !
@Column({ name: 'main_category_name', length: 255 })
mainCategoryName!: string; // เติม !
@Column({ type: 'text', nullable: true })
description?: string; // nullable
@Column({ name: 'sort_order', default: 0 })
sortOrder!: number; // เติม !
@Column({ name: 'is_active', default: true })
isActive!: boolean; // เติม !
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // เติม !
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date; // เติม !
}

View File

@@ -0,0 +1,71 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
ManyToMany,
JoinTable,
} from 'typeorm';
import { ShopDrawing } from './shop-drawing.entity';
import { ContractDrawing } from './contract-drawing.entity';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
@Entity('shop_drawing_revisions')
export class ShopDrawingRevision {
@PrimaryGeneratedColumn()
id!: number; // เติม !
@Column({ name: 'shop_drawing_id' })
shopDrawingId!: number; // เติม !
@Column({ name: 'revision_number' })
revisionNumber!: number; // เติม !
@Column({ name: 'revision_label', length: 10, nullable: true })
revisionLabel?: string; // nullable ใช้ ?
@Column({ name: 'revision_date', type: 'date', nullable: true })
revisionDate?: Date; // nullable ใช้ ?
@Column({ type: 'text', nullable: true })
description?: string; // nullable ใช้ ?
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // เติม !
// Relations
@ManyToOne(() => ShopDrawing, (shopDrawing) => shopDrawing.revisions, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'shop_drawing_id' })
shopDrawing!: ShopDrawing; // เติม !
// References to Contract Drawings (M:N)
@ManyToMany(() => ContractDrawing)
@JoinTable({
name: 'shop_drawing_revision_contract_refs',
joinColumn: {
name: 'shop_drawing_revision_id',
referencedColumnName: 'id',
},
inverseJoinColumn: {
name: 'contract_drawing_id',
referencedColumnName: 'id',
},
})
contractDrawings!: ContractDrawing[]; // เติม !
// Attachments (M:N)
@ManyToMany(() => Attachment)
@JoinTable({
name: 'shop_drawing_revision_attachments',
joinColumn: {
name: 'shop_drawing_revision_id',
referencedColumnName: 'id',
},
inverseJoinColumn: { name: 'attachment_id', referencedColumnName: 'id' },
})
attachments!: Attachment[]; // เติม ! (ตัวที่ error)
}

View File

@@ -0,0 +1,45 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
@Entity('shop_drawing_sub_categories')
export class ShopDrawingSubCategory {
@PrimaryGeneratedColumn()
id!: number; // เติม ! (ตัวที่ error)
@Column({ name: 'sub_category_code', length: 50, unique: true })
subCategoryCode!: string; // เติม !
@Column({ name: 'sub_category_name', length: 255 })
subCategoryName!: string; // เติม !
@Column({ name: 'main_category_id' })
mainCategoryId!: number; // เติม !
@Column({ type: 'text', nullable: true })
description?: string; // nullable ใช้ ?
@Column({ name: 'sort_order', default: 0 })
sortOrder!: number; // เติม !
@Column({ name: 'is_active', default: true })
isActive!: boolean; // เติม !
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // เติม !
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date; // เติม !
// Relation to Main Category
@ManyToOne(() => ShopDrawingMainCategory)
@JoinColumn({ name: 'main_category_id' })
mainCategory!: ShopDrawingMainCategory; // เติม !
}

View File

@@ -0,0 +1,67 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
OneToMany,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ShopDrawingRevision } from './shop-drawing-revision.entity';
import { Project } from '../../project/entities/project.entity';
import { ShopDrawingMainCategory } from './shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './shop-drawing-sub-category.entity';
@Entity('shop_drawings')
export class ShopDrawing {
@PrimaryGeneratedColumn()
id!: number; // เติม !
@Column({ name: 'project_id' })
projectId!: number; // เติม !
@Column({ name: 'drawing_number', length: 100, unique: true })
drawingNumber!: string; // เติม !
@Column({ length: 500 })
title!: string; // เติม !
@Column({ name: 'main_category_id' })
mainCategoryId!: number; // เติม !
@Column({ name: 'sub_category_id' })
subCategoryId!: number; // เติม !
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date; // เติม !
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date; // เติม !
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date; // nullable
@Column({ name: 'updated_by', nullable: true })
updatedBy?: number; // nullable
// --- Relations ---
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project!: Project; // เติม ! (ตัวที่ error)
@ManyToOne(() => ShopDrawingMainCategory)
@JoinColumn({ name: 'main_category_id' })
mainCategory!: ShopDrawingMainCategory; // เติม !
@ManyToOne(() => ShopDrawingSubCategory)
@JoinColumn({ name: 'sub_category_id' })
subCategory!: ShopDrawingSubCategory; // เติม !
@OneToMany(() => ShopDrawingRevision, (revision) => revision.shopDrawing, {
cascade: true,
})
revisions!: ShopDrawingRevision[]; // เติม !
}

View File

@@ -0,0 +1,61 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ShopDrawingService } from './shop-drawing.service';
import { CreateShopDrawingDto } from './dto/create-shop-drawing.dto';
import { SearchShopDrawingDto } from './dto/search-shop-drawing.dto';
import { CreateShopDrawingRevisionDto } from './dto/create-shop-drawing-revision.dto';
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 { User } from '../user/entities/user.entity';
@ApiTags('Shop Drawings')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Controller('drawings/shop')
export class ShopDrawingController {
constructor(private readonly shopDrawingService: ShopDrawingService) {}
@Post()
@ApiOperation({ summary: 'Create new Shop Drawing with initial revision' })
@RequirePermission('drawing.create') // อ้างอิง Permission จาก Seed
create(@Body() createDto: CreateShopDrawingDto, @CurrentUser() user: User) {
return this.shopDrawingService.create(createDto, user);
}
@Get()
@ApiOperation({ summary: 'Search Shop Drawings' })
@RequirePermission('drawing.view')
findAll(@Query() searchDto: SearchShopDrawingDto) {
return this.shopDrawingService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get Shop Drawing details with revisions' })
@RequirePermission('drawing.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.shopDrawingService.findOne(id);
}
@Post(':id/revisions')
@ApiOperation({ summary: 'Add new revision to existing Shop Drawing' })
@RequirePermission('drawing.create') // หรือ drawing.edit ตาม Logic องค์กร
createRevision(
@Param('id', ParseIntPipe) id: number,
@Body() createRevisionDto: CreateShopDrawingRevisionDto,
) {
return this.shopDrawingService.createRevision(id, createRevisionDto);
}
}

View File

@@ -0,0 +1,321 @@
import {
Injectable,
NotFoundException,
BadRequestException,
InternalServerErrorException,
ConflictException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In, Brackets } from 'typeorm';
// Entities
import { ShopDrawing } from './entities/shop-drawing.entity';
import { ShopDrawingRevision } from './entities/shop-drawing-revision.entity';
import { ContractDrawing } from './entities/contract-drawing.entity';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { User } from '../user/entities/user.entity';
// DTOs
import { CreateShopDrawingDto } from './dto/create-shop-drawing.dto';
import { CreateShopDrawingRevisionDto } from './dto/create-shop-drawing-revision.dto';
import { SearchShopDrawingDto } from './dto/search-shop-drawing.dto';
// Services
import { FileStorageService } from '../../common/file-storage/file-storage.service';
@Injectable()
export class ShopDrawingService {
private readonly logger = new Logger(ShopDrawingService.name);
constructor(
@InjectRepository(ShopDrawing)
private shopDrawingRepo: Repository<ShopDrawing>,
@InjectRepository(ShopDrawingRevision)
private revisionRepo: Repository<ShopDrawingRevision>,
@InjectRepository(ContractDrawing)
private contractDrawingRepo: Repository<ContractDrawing>,
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private fileStorageService: FileStorageService,
private dataSource: DataSource,
) {}
/**
* สร้าง Shop Drawing ใหม่ พร้อม Revision แรก (Rev 0)
* ทำงานภายใต้ Database Transaction เดียวกัน
*/
async create(createDto: CreateShopDrawingDto, user: User) {
// 1. ตรวจสอบเลขที่แบบซ้ำ (Unique Check)
const exists = await this.shopDrawingRepo.findOne({
where: { drawingNumber: createDto.drawingNumber },
});
if (exists) {
throw new ConflictException(
`Drawing number "${createDto.drawingNumber}" already exists.`,
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 2. เตรียมข้อมูล Relations (Contract Drawings & Attachments)
let contractDrawings: ContractDrawing[] = [];
if (createDto.contractDrawingIds?.length) {
contractDrawings = await this.contractDrawingRepo.findBy({
id: In(createDto.contractDrawingIds),
});
}
let attachments: Attachment[] = [];
if (createDto.attachmentIds?.length) {
attachments = await this.attachmentRepo.findBy({
id: In(createDto.attachmentIds),
});
}
// 3. สร้าง Master Shop Drawing
const shopDrawing = queryRunner.manager.create(ShopDrawing, {
projectId: createDto.projectId,
drawingNumber: createDto.drawingNumber,
title: createDto.title,
mainCategoryId: createDto.mainCategoryId,
subCategoryId: createDto.subCategoryId,
updatedBy: user.user_id,
});
const savedShopDrawing = await queryRunner.manager.save(shopDrawing);
// 4. สร้าง First Revision (Rev 0)
const revision = queryRunner.manager.create(ShopDrawingRevision, {
shopDrawingId: savedShopDrawing.id,
revisionNumber: 0, // เริ่มต้นที่ 0 เสมอ
revisionLabel: createDto.revisionLabel || '0',
revisionDate: createDto.revisionDate
? new Date(createDto.revisionDate)
: new Date(),
description: createDto.description,
contractDrawings: contractDrawings, // ผูก M:N Relation
attachments: attachments, // ผูก M:N Relation
});
await queryRunner.manager.save(revision);
// 5. Commit Files (ย้ายไฟล์จาก Temp -> Permanent)
if (createDto.attachmentIds?.length) {
// ✅ FIX: ใช้ commitFiles และแปลง number[] -> string[]
await this.fileStorageService.commit(
createDto.attachmentIds.map(String),
);
}
await queryRunner.commitTransaction();
return {
...savedShopDrawing,
currentRevision: revision,
};
} catch (err) {
await queryRunner.rollbackTransaction();
// ✅ FIX: Cast err เป็น Error
this.logger.error(
`Failed to create shop drawing: ${(err as Error).message}`,
);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* เพิ่ม Revision ใหม่ให้กับ Shop Drawing เดิม (Add Revision)
* เช่น Rev 0 -> Rev A
*/
async createRevision(
shopDrawingId: number,
createDto: CreateShopDrawingRevisionDto,
) {
// 1. ตรวจสอบว่ามี Master Drawing อยู่จริง
const shopDrawing = await this.shopDrawingRepo.findOneBy({
id: shopDrawingId,
});
if (!shopDrawing) {
throw new NotFoundException('Shop Drawing not found');
}
// 2. ตรวจสอบ Label ซ้ำใน Drawing เดียวกัน
const exists = await this.revisionRepo.findOne({
where: { shopDrawingId, revisionLabel: createDto.revisionLabel },
});
if (exists) {
throw new ConflictException(
`Revision label "${createDto.revisionLabel}" already exists for this drawing.`,
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 3. เตรียม Relations
let contractDrawings: ContractDrawing[] = [];
if (createDto.contractDrawingIds?.length) {
contractDrawings = await this.contractDrawingRepo.findBy({
id: In(createDto.contractDrawingIds),
});
}
let attachments: Attachment[] = [];
if (createDto.attachmentIds?.length) {
attachments = await this.attachmentRepo.findBy({
id: In(createDto.attachmentIds),
});
}
// 4. หา Revision Number ล่าสุดเพื่อ +1 (Running Number ภายใน)
const latestRev = await this.revisionRepo.findOne({
where: { shopDrawingId },
order: { revisionNumber: 'DESC' },
});
const nextRevNum = (latestRev?.revisionNumber ?? -1) + 1;
// 5. บันทึก Revision ใหม่
const revision = queryRunner.manager.create(ShopDrawingRevision, {
shopDrawingId,
revisionNumber: nextRevNum,
revisionLabel: createDto.revisionLabel,
revisionDate: createDto.revisionDate
? new Date(createDto.revisionDate)
: new Date(),
description: createDto.description,
contractDrawings: contractDrawings,
attachments: attachments,
});
await queryRunner.manager.save(revision);
// 6. Commit Files
if (createDto.attachmentIds?.length) {
// ✅ FIX: ใช้ commitFiles และแปลง number[] -> string[]
await this.fileStorageService.commit(
createDto.attachmentIds.map(String),
);
}
await queryRunner.commitTransaction();
return revision;
} catch (err) {
await queryRunner.rollbackTransaction();
// ✅ FIX: Cast err เป็น Error
this.logger.error(`Failed to create revision: ${(err as Error).message}`);
throw err;
} finally {
await queryRunner.release();
}
}
/**
* ค้นหา Shop Drawing (Search & Filter)
* รองรับการค้นหาด้วย Text และกรองตาม Category
*/
async findAll(searchDto: SearchShopDrawingDto) {
const {
projectId,
mainCategoryId,
subCategoryId,
search,
page = 1,
pageSize = 20,
} = searchDto;
const query = this.shopDrawingRepo
.createQueryBuilder('sd')
.leftJoinAndSelect('sd.mainCategory', 'mainCat')
.leftJoinAndSelect('sd.subCategory', 'subCat')
.leftJoinAndSelect('sd.revisions', 'rev')
.where('sd.projectId = :projectId', { projectId });
if (mainCategoryId) {
query.andWhere('sd.mainCategoryId = :mainCategoryId', { mainCategoryId });
}
if (subCategoryId) {
query.andWhere('sd.subCategoryId = :subCategoryId', { subCategoryId });
}
if (search) {
query.andWhere(
new Brackets((qb) => {
qb.where('sd.drawingNumber LIKE :search', {
search: `%${search}%`,
}).orWhere('sd.title LIKE :search', { search: `%${search}%` });
}),
);
}
query.orderBy('sd.updatedAt', 'DESC');
const skip = (page - 1) * pageSize;
query.skip(skip).take(pageSize);
const [items, total] = await query.getManyAndCount();
// Transform Data: เลือก Revision ล่าสุดมาแสดงเป็น currentRevision
const transformedItems = items.map((item) => {
item.revisions.sort((a, b) => b.revisionNumber - a.revisionNumber);
const currentRevision = item.revisions[0];
return {
...item,
currentRevision,
revisions: undefined,
};
});
return {
data: transformedItems,
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
};
}
/**
* ดูรายละเอียด Shop Drawing (Get One)
*/
async findOne(id: number) {
const shopDrawing = await this.shopDrawingRepo.findOne({
where: { id },
relations: [
'mainCategory',
'subCategory',
'revisions',
'revisions.attachments',
'revisions.contractDrawings',
],
order: {
revisions: { revisionNumber: 'DESC' },
},
});
if (!shopDrawing) {
throw new NotFoundException(`Shop Drawing ID ${id} not found`);
}
return shopDrawing;
}
/**
* ลบ Shop Drawing (Soft Delete)
*/
async remove(id: number, user: User) {
const shopDrawing = await this.findOne(id);
shopDrawing.updatedBy = user.user_id;
await this.shopDrawingRepo.save(shopDrawing);
return this.shopDrawingRepo.softRemove(shopDrawing);
}
}

View File

@@ -1,7 +1,7 @@
import { Controller, Post, Body, Param, UseGuards } from '@nestjs/common';
import { JsonSchemaService } from './json-schema.service.js';
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js';
import { RbacGuard } from '../../common/auth/rbac.guard.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
@Controller('json-schemas')

View File

@@ -1,6 +1,6 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ProjectService } from './project.service.js';
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
@Controller('projects')
@UseGuards(JwtAuthGuard)

View File

@@ -16,8 +16,8 @@ import { UpdateUserDto } from './dto/update-user.dto.js';
import { AssignRoleDto } from './dto/assign-role.dto.js'; // <--- Import DTO
import { UserAssignmentService } from './user-assignment.service.js'; // <--- Import Service ใหม่
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js';
import { RbacGuard } from '../../common/auth/rbac.guard.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
@Controller('users')
@@ -70,4 +70,9 @@ export class UserController {
assignRole(@Body() dto: AssignRoleDto, @Request() req: any) {
return this.assignmentService.assignRole(dto, req.user);
}
@Get('me/permissions')
@UseGuards(JwtAuthGuard) // No RbacGuard here to avoid circular dependency check issues
getMyPermissions(@Request() req: any) {
return this.userService.getUserPermissions(req.user.user_id);
}
}

View File

@@ -5,5 +5,8 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
}
}

View File

@@ -0,0 +1,113 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { JwtService } from '@nestjs/jwt';
import { DataSource } from 'typeorm';
import { RoutingTemplate } from '../src/modules/correspondence/entities/routing-template.entity';
import { RoutingTemplateStep } from '../src/modules/correspondence/entities/routing-template-step.entity';
describe('Phase 3 Workflow (E2E)', () => {
let app: INestApplication;
let jwtService: JwtService;
let dataSource: DataSource;
let templateId: number;
let correspondenceId: number;
// Users
const editorUser = { user_id: 3, username: 'editor01', organization_id: 41 }; // Editor01 (Org 41)
const adminUser = { user_id: 2, username: 'admin', organization_id: 1 }; // Admin (Org 1)
let editorToken: string;
let adminToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
jwtService = moduleFixture.get<JwtService>(JwtService);
dataSource = moduleFixture.get<DataSource>(DataSource);
// Generate Tokens
editorToken = jwtService.sign({
username: editorUser.username,
sub: editorUser.user_id,
});
adminToken = jwtService.sign({
username: adminUser.username,
sub: adminUser.user_id,
});
// Seed Template
const templateRepo = dataSource.getRepository(RoutingTemplate);
const stepRepo = dataSource.getRepository(RoutingTemplateStep);
const template = templateRepo.create({
templateName: 'E2E Test Template',
isActive: true,
});
const savedTemplate = await templateRepo.save(template);
templateId = savedTemplate.id;
const step = stepRepo.create({
templateId: savedTemplate.id,
sequence: 1,
toOrganizationId: adminUser.organization_id, // Send to Admin's Org
stepPurpose: 'FOR_APPROVAL',
});
await stepRepo.save(step);
});
afterAll(async () => {
// Cleanup
if (dataSource) {
const templateRepo = dataSource.getRepository(RoutingTemplate);
await templateRepo.delete(templateId);
// Correspondence cleanup might be needed if not using a test DB
}
await app.close();
});
it('/correspondences (POST) - Create Document', async () => {
const response = await request(app.getHttpServer())
.post('/correspondences')
.set('Authorization', `Bearer ${editorToken}`)
.send({
projectId: 1, // LCBP3
typeId: 1, // RFA (Assuming ID 1 exists from seed)
title: 'E2E Workflow Test Document',
details: { question: 'Testing Workflow' },
})
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body).toHaveProperty('correspondenceNumber');
correspondenceId = response.body.id;
console.log('Created Correspondence ID:', correspondenceId);
});
it('/correspondences/:id/submit (POST) - Submit Workflow', async () => {
await request(app.getHttpServer())
.post(`/correspondences/${correspondenceId}/submit`)
.set('Authorization', `Bearer ${editorToken}`)
.send({
templateId: templateId,
})
.expect(201);
});
it('/correspondences/:id/workflow/action (POST) - Approve Step', async () => {
await request(app.getHttpServer())
.post(`/correspondences/${correspondenceId}/workflow/action`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
action: 'APPROVE',
comment: 'E2E Approved',
})
.expect(201);
});
});

View File

@@ -0,0 +1,14 @@
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { RoutingTemplate } from '../src/modules/correspondence/entities/routing-template.entity';
import { Test, TestingModule } from '@nestjs/testing';
describe('Simple Test', () => {
it('should pass', async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
expect(moduleFixture).toBeDefined();
});
});