251122:1700 Phase 4
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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: [
|
||||
|
||||
19
backend/src/common/decorators/current-user.decorator.ts
Normal file
19
backend/src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsInt, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class AddReferenceDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
targetId!: number;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
76
backend/src/modules/drawing/contract-drawing.controller.ts
Normal file
76
backend/src/modules/drawing/contract-drawing.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
248
backend/src/modules/drawing/contract-drawing.service.ts
Normal file
248
backend/src/modules/drawing/contract-drawing.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
72
backend/src/modules/drawing/drawing-master-data.service.ts
Normal file
72
backend/src/modules/drawing/drawing-master-data.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
63
backend/src/modules/drawing/drawing.module.ts
Normal file
63
backend/src/modules/drawing/drawing.module.ts
Normal 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 {}
|
||||
@@ -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[]; // ✅ ใส่ ?
|
||||
}
|
||||
@@ -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: ใส่ ?
|
||||
}
|
||||
47
backend/src/modules/drawing/dto/create-shop-drawing.dto.ts
Normal file
47
backend/src/modules/drawing/dto/create-shop-drawing.dto.ts
Normal 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[]; // ?
|
||||
}
|
||||
@@ -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 ไม่ต้องใส่ ! หรือ ?
|
||||
}
|
||||
32
backend/src/modules/drawing/dto/search-shop-drawing.dto.ts
Normal file
32
backend/src/modules/drawing/dto/search-shop-drawing.dto.ts
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateContractDrawingDto } from './create-contract-drawing.dto';
|
||||
|
||||
export class UpdateContractDrawingDto extends PartialType(
|
||||
CreateContractDrawingDto,
|
||||
) {}
|
||||
@@ -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; // เติม !
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 [] ถ้าไม่มี)
|
||||
}
|
||||
@@ -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; // เติม !
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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; // เติม !
|
||||
}
|
||||
67
backend/src/modules/drawing/entities/shop-drawing.entity.ts
Normal file
67
backend/src/modules/drawing/entities/shop-drawing.entity.ts
Normal 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[]; // เติม !
|
||||
}
|
||||
61
backend/src/modules/drawing/shop-drawing.controller.ts
Normal file
61
backend/src/modules/drawing/shop-drawing.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
321
backend/src/modules/drawing/shop-drawing.service.ts
Normal file
321
backend/src/modules/drawing/shop-drawing.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user