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
@@ -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);
}
}
@@ -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);
}
}
@@ -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
});
}
}
@@ -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: ใส่ ?
}
@@ -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 ไม่ต้องใส่ ! หรือ ?
}
@@ -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; // เติม !
}
@@ -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[]; // เติม !
}
@@ -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);
}
}
@@ -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);
}
}