From bfa8d3df83790735a6853ca79876c18d7b090e75 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 26 Nov 2025 14:38:24 +0700 Subject: [PATCH] 251126:1300 test run --- .vscode/nap-dms.lcbp3.code-workspace | 6 +- .../correspondence/correspondence.service.ts | 152 +++-------- .../dto/create-correspondence.dto.ts | 16 +- .../correspondence-sub-type.entity.ts | 44 +++ .../document-numbering.module.ts | 29 +- .../document-numbering.service.ts | 252 +++++++++++++----- .../document-number-counter.entity.ts | 11 +- .../document-numbering.interface.ts | 24 ++ .../master/entities/discipline.entity.ts | 45 ++++ backend/src/modules/rfa/dto/create-rfa.dto.ts | 7 +- backend/src/modules/rfa/rfa.service.ts | 83 +++--- .../transmittal/transmittal.service.ts | 49 ++-- 12 files changed, 447 insertions(+), 271 deletions(-) create mode 100644 backend/src/modules/correspondence/entities/correspondence-sub-type.entity.ts create mode 100644 backend/src/modules/document-numbering/interfaces/document-numbering.interface.ts create mode 100644 backend/src/modules/master/entities/discipline.entity.ts diff --git a/.vscode/nap-dms.lcbp3.code-workspace b/.vscode/nap-dms.lcbp3.code-workspace index 4b6afd6..2bcef52 100644 --- a/.vscode/nap-dms.lcbp3.code-workspace +++ b/.vscode/nap-dms.lcbp3.code-workspace @@ -21,6 +21,10 @@ "username": "root" } ], - "editor.fontSize": 15 + "editor.fontSize": 15, + "editor.codeActionsOnSave": { + + }, + "editor.codeActions.triggerOnFocusChange": true } } \ No newline at end of file diff --git a/backend/src/modules/correspondence/correspondence.service.ts b/backend/src/modules/correspondence/correspondence.service.ts index 09f423f..2139e4f 100644 --- a/backend/src/modules/correspondence/correspondence.service.ts +++ b/backend/src/modules/correspondence/correspondence.service.ts @@ -18,7 +18,7 @@ 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 { CorrespondenceReference } from './entities/correspondence-reference.entity.js'; import { User } from '../user/entities/user.entity.js'; // DTOs @@ -35,7 +35,8 @@ import { DocumentNumberingService } from '../document-numbering/document-numberi 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'; -import { SearchService } from '../search/search.service'; // Import SearchService +import { SearchService } from '../search/search.service.js'; + @Injectable() export class CorrespondenceService { private readonly logger = new Logger(CorrespondenceService.name); @@ -61,19 +62,10 @@ export class CorrespondenceService { private workflowEngine: WorkflowEngineService, private userService: UserService, private dataSource: DataSource, - private searchService: SearchService, // Inject + private searchService: SearchService, ) {} - /** - * สร้างเอกสารใหม่ (Create Document) - * รองรับ Impersonation Logic: Superadmin สามารถสร้างในนามองค์กรอื่นได้ - * - * @param createDto ข้อมูลสำหรับการสร้างเอกสาร - * @param user ผู้ใช้งานที่ทำการสร้าง - * @returns ข้อมูลเอกสารที่สร้างเสร็จแล้ว - */ async create(createDto: CreateCorrespondenceDto, user: User) { - // 1. ตรวจสอบข้อมูลพื้นฐาน (Basic Validation) const type = await this.typeRepo.findOne({ where: { id: createDto.typeId }, }); @@ -88,11 +80,8 @@ export class CorrespondenceService { ); } - // 2. Impersonation Logic & Organization Context - // กำหนด Org เริ่มต้นเป็นของผู้ใช้งานปัจจุบัน let userOrgId = user.primaryOrganizationId; - // Fallback: หากใน Token ไม่มี Org ID ให้ดึงจาก DB อีกครั้งเพื่อความชัวร์ if (!userOrgId) { const fullUser = await this.userService.findOne(user.user_id); if (fullUser) { @@ -100,91 +89,82 @@ export class CorrespondenceService { } } - // ตรวจสอบกรณีต้องการสร้างในนามองค์กรอื่น (Impersonation) + // Impersonation Logic 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) { - // 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 { - // 4.1 ขอเลขที่เอกสาร (Double-Lock Mechanism ผ่าน NumberingService) - // TODO: Fetch ORG_CODE จาก DB จริงๆ โดยใช้ userOrgId - const orgCode = 'ORG'; // Mock ไว้ก่อน ควร query จาก Organization Entity + const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity - const docNumber = await this.numberingService.generateNextNumber( - createDto.projectId, - userOrgId, // ใช้ ID ของเจ้าของเอกสารจริง (Originator) - createDto.typeId, - new Date().getFullYear(), - { + // [FIXED] เรียกใช้แบบ Object Context ตาม Requirement 6B + const docNumber = await this.numberingService.generateNextNumber({ + projectId: createDto.projectId, + originatorId: userOrgId, + typeId: createDto.typeId, + disciplineId: createDto.disciplineId, // ส่ง Discipline (ถ้ามี) + subTypeId: createDto.subTypeId, // ส่ง SubType (ถ้ามี) + year: new Date().getFullYear(), + customTokens: { TYPE_CODE: type.typeCode, ORG_CODE: orgCode, }, - ); + }); - // 4.2 สร้าง Correspondence (หัวจดหมาย) const correspondence = queryRunner.manager.create(Correspondence, { correspondenceNumber: docNumber, correspondenceTypeId: createDto.typeId, + disciplineId: createDto.disciplineId, // บันทึก Discipline ลง DB projectId: createDto.projectId, - originatorId: userOrgId, // บันทึก Org ที่ถูกต้อง + originatorId: userOrgId, isInternal: createDto.isInternal || false, createdBy: user.user_id, }); const savedCorr = await queryRunner.manager.save(correspondence); - // 4.3 สร้าง Revision แรก (Rev 0) const revision = queryRunner.manager.create(CorrespondenceRevision, { correspondenceId: savedCorr.id, revisionNumber: 0, - revisionLabel: 'A', // หรือเริ่มที่ 0 แล้วแต่ Business Logic + revisionLabel: 'A', isCurrent: true, statusId: statusDraft.id, title: createDto.title, - description: createDto.description, // ถ้ามีใน DTO + description: createDto.description, details: createDto.details, createdBy: user.user_id, }); await queryRunner.manager.save(revision); - await queryRunner.commitTransaction(); // Transaction จบแล้ว ข้อมูลชัวร์แล้ว - // 🔥 Fire & Forget: ไม่ต้อง await ผลลัพธ์เพื่อความเร็ว (หรือใช้ Queue ก็ได้) + await queryRunner.commitTransaction(); + this.searchService.indexDocument({ id: savedCorr.id, type: 'correspondence', @@ -211,10 +191,7 @@ export class CorrespondenceService { } } - /** - * ค้นหาเอกสาร (Find All) - * รองรับการกรองและค้นหา - */ + // ... (method อื่นๆ คงเดิม) async findAll(searchDto: SearchCorrespondenceDto = {}) { const { search, typeId, projectId, statusId } = searchDto; @@ -224,7 +201,7 @@ export class CorrespondenceService { .leftJoinAndSelect('corr.type', 'type') .leftJoinAndSelect('corr.project', 'project') .leftJoinAndSelect('corr.originator', 'org') - .where('rev.isCurrent = :isCurrent', { isCurrent: true }); // ดูเฉพาะ Rev ปัจจุบัน + .where('rev.isCurrent = :isCurrent', { isCurrent: true }); if (projectId) { query.andWhere('corr.projectId = :projectId', { projectId }); @@ -250,21 +227,15 @@ export class CorrespondenceService { return query.getMany(); } - /** - * ดึงข้อมูลเอกสารรายตัว (Find One) - * พร้อม Relations ที่จำเป็น - */ async findOne(id: number) { const correspondence = await this.correspondenceRepo.findOne({ where: { id }, relations: [ 'revisions', - 'revisions.status', // สถานะของ Revision + 'revisions.status', 'type', 'project', 'originator', - // 'tags', // ถ้ามี Relation - // 'attachments' // ถ้ามี Relation ผ่าน Junction ], }); @@ -274,10 +245,6 @@ export class CorrespondenceService { return correspondence; } - /** - * ส่งเอกสารเข้า Workflow (Submit) - * สร้าง Routing เริ่มต้นตาม Template - */ async submit(correspondenceId: number, templateId: number, user: User) { const correspondence = await this.correspondenceRepo.findOne({ where: { id: correspondenceId }, @@ -293,9 +260,6 @@ export class CorrespondenceService { throw new NotFoundException('Current revision not found'); } - // ตรวจสอบสถานะปัจจุบัน (ต้องเป็น DRAFT หรือสถานะที่แก้ได้) - // TODO: เพิ่ม Logic ตรวจสอบ Status ID ว่าเป็น DRAFT หรือไม่ - const template = await this.templateRepo.findOne({ where: { id: templateId }, relations: ['steps'], @@ -315,25 +279,22 @@ export class CorrespondenceService { try { const firstStep = template.steps[0]; - // สร้าง Routing Record แรก const routing = queryRunner.manager.create(CorrespondenceRouting, { - correspondenceId: currentRevision.id, // ผูกกับ Revision - templateId: template.id, // บันทึก templateId ไว้ใช้อ้างอิง + correspondenceId: currentRevision.id, + templateId: template.id, 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) { @@ -344,15 +305,11 @@ export class CorrespondenceService { } } - /** - * ประมวลผล Action ใน Workflow (Approve/Reject/Etc.) - */ async processAction( correspondenceId: number, dto: WorkflowActionDto, user: User, ) { - // 1. Find Document & Current Revision const correspondence = await this.correspondenceRepo.findOne({ where: { id: correspondenceId }, relations: ['revisions'], @@ -365,8 +322,6 @@ export class CorrespondenceService { if (!currentRevision) throw new NotFoundException('Current revision not found'); - // 2. Find Active Routing Step (Status = SENT) - // หาสเต็ปล่าสุดที่ส่งมาถึง Org ของเรา และสถานะเป็น SENT const currentRouting = await this.routingRepo.findOne({ where: { correspondenceId: currentRevision.id, @@ -382,16 +337,12 @@ export class CorrespondenceService { ); } - // 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', ); } - // 4. Load Template to find Next Step Config if (!currentRouting.templateId) { throw new InternalServerErrorException( 'Routing record missing templateId', @@ -410,7 +361,6 @@ export class CorrespondenceService { const totalSteps = template.steps.length; const currentSeq = currentRouting.sequence; - // 5. Calculate Next State using Workflow Engine Service const result = this.workflowEngine.processAction( currentSeq, totalSteps, @@ -418,13 +368,11 @@ export class CorrespondenceService { dto.returnToSequence, ); - // 6. Execute Database Updates const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { - // 6.1 Update Current Step currentRouting.status = dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED'; currentRouting.processedByUserId = user.user_id; @@ -433,15 +381,12 @@ export class CorrespondenceService { await queryRunner.manager.save(currentRouting); - // 6.2 Create Next Step (If exists and not rejected/completed) if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) { - // ค้นหา Config ของ Step ถัดไปจาก Template const nextStepConfig = template.steps.find( (s) => s.sequence === result.nextStepSequence, ); if (!nextStepConfig) { - // อาจจะเป็นกรณี End of Workflow หรือ Logic Error this.logger.warn( `Next step ${result.nextStepSequence} not found in template`, ); @@ -452,8 +397,8 @@ export class CorrespondenceService { correspondenceId: currentRevision.id, templateId: template.id, sequence: result.nextStepSequence, - fromOrganizationId: user.primaryOrganizationId, // ส่งจากคนปัจจุบัน - toOrganizationId: nextStepConfig.toOrganizationId, // ไปยังคนถัดไปตาม Template + fromOrganizationId: user.primaryOrganizationId, + toOrganizationId: nextStepConfig.toOrganizationId, stepPurpose: nextStepConfig.stepPurpose, status: 'SENT', dueDate: new Date( @@ -466,12 +411,6 @@ export class CorrespondenceService { } } - // 6.3 Update Document Status (Optional / Based on result) - if (result.shouldUpdateStatus) { - // Logic เปลี่ยนสถานะ revision เช่นจาก SUBMITTED -> APPROVED - // await this.updateDocumentStatus(currentRevision, result.documentStatus, queryRunner); - } - await queryRunner.commitTransaction(); return { message: 'Action processed successfully', result }; } catch (err) { @@ -482,14 +421,7 @@ export class CorrespondenceService { } } - // --- 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 }, @@ -499,12 +431,10 @@ export class CorrespondenceService { 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, @@ -513,10 +443,9 @@ export class CorrespondenceService { }); if (exists) { - return exists; // ถ้ามีแล้วก็คืนตัวเดิมไป (Idempotency) + return exists; } - // 4. สร้าง Reference const ref = this.referenceRepo.create({ sourceId: id, targetId: dto.targetId, @@ -525,9 +454,6 @@ export class CorrespondenceService { return this.referenceRepo.save(ref); } - /** - * ลบเอกสารอ้างอิง (Remove Reference) - */ async removeReference(id: number, targetId: number) { const result = await this.referenceRepo.delete({ sourceId: id, @@ -539,23 +465,17 @@ export class CorrespondenceService { } } - /** - * ดึงรายการเอกสารอ้างอิง (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 เพื่อเอาข้อมูลเอกสารปลายทาง + relations: ['target', 'target.type'], }); - // ดึงรายการที่มาอ้างถึงเอกสารนี้ (Incoming: Others -> This) const incoming = await this.referenceRepo.find({ where: { targetId: id }, - relations: ['source', 'source.type'], // Join เพื่อเอาข้อมูลเอกสารต้นทาง + relations: ['source', 'source.type'], }); return { outgoing, incoming }; } -} +} \ No newline at end of file diff --git a/backend/src/modules/correspondence/dto/create-correspondence.dto.ts b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts index a8ce50e..101fc58 100644 --- a/backend/src/modules/correspondence/dto/create-correspondence.dto.ts +++ b/backend/src/modules/correspondence/dto/create-correspondence.dto.ts @@ -1,3 +1,4 @@ +// File: src/modules/correspondence/dto/create-correspondence.dto.ts import { IsInt, IsString, @@ -16,6 +17,14 @@ export class CreateCorrespondenceDto { @IsNotEmpty() typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER) + @IsInt() + @IsOptional() + disciplineId?: number; // [Req 6B] สาขางาน (เช่น GEN, STR) + + @IsInt() + @IsOptional() + subTypeId?: number; // [Req 6B] ประเภทย่อย (เช่น MAT, SHP สำหรับ Transmittal/RFA) + @IsString() @IsNotEmpty() title!: string; @@ -36,9 +45,4 @@ export class CreateCorrespondenceDto { @IsInt() @IsOptional() originatorId?: number; - - // (Optional) ถ้าจะมีการแนบไฟล์มาด้วยเลย - // @IsArray() - // @IsString({ each: true }) - // attachmentTempIds?: string[]; -} +} \ No newline at end of file diff --git a/backend/src/modules/correspondence/entities/correspondence-sub-type.entity.ts b/backend/src/modules/correspondence/entities/correspondence-sub-type.entity.ts new file mode 100644 index 0000000..96b78ae --- /dev/null +++ b/backend/src/modules/correspondence/entities/correspondence-sub-type.entity.ts @@ -0,0 +1,44 @@ +// File: src/modules/master/entities/correspondence-sub-type.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { Contract } from '../../project/entities/contract.entity'; // ปรับ path ตามจริง +import { CorrespondenceType } from './correspondence-type.entity'; // ปรับ path ตามจริง + +@Entity('correspondence_sub_types') +export class CorrespondenceSubType { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'contract_id' }) + contractId!: number; + + @Column({ name: 'correspondence_type_id' }) + correspondenceTypeId!: number; + + @Column({ name: 'sub_type_code', length: 20 }) + subTypeCode!: string; // เช่น MAT, SHP + + @Column({ name: 'sub_type_name', nullable: true }) + subTypeName?: string; + + @Column({ name: 'sub_type_number', length: 10, nullable: true }) + subTypeNumber?: string; // เลขรหัสสำหรับ Running Number (เช่น 11, 22) + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // Relations + @ManyToOne(() => Contract) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @ManyToOne(() => CorrespondenceType) + @JoinColumn({ name: 'correspondence_type_id' }) + correspondenceType?: CorrespondenceType; +} diff --git a/backend/src/modules/document-numbering/document-numbering.module.ts b/backend/src/modules/document-numbering/document-numbering.module.ts index 6ae8934..d0eba96 100644 --- a/backend/src/modules/document-numbering/document-numbering.module.ts +++ b/backend/src/modules/document-numbering/document-numbering.module.ts @@ -1,14 +1,33 @@ +// File: src/modules/document-numbering/document-numbering.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DocumentNumberingService } from './document-numbering.service.js'; -import { DocumentNumberFormat } from './entities/document-number-format.entity.js'; -import { DocumentNumberCounter } from './entities/document-number-counter.entity.js'; +import { ConfigModule } from '@nestjs/config'; + +import { DocumentNumberingService } from './document-numbering.service'; +import { DocumentNumberFormat } from './entities/document-number-format.entity'; +import { DocumentNumberCounter } from './entities/document-number-counter.entity'; + +// Master Entities ที่ต้องใช้ Lookup +import { Project } from '../project/entities/project.entity'; +import { Organization } from '../project/entities/organization.entity'; +import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; +import { Discipline } from '../master/entities/discipline.entity'; +import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter]), + ConfigModule, + TypeOrmModule.forFeature([ + DocumentNumberFormat, + DocumentNumberCounter, + Project, + Organization, + CorrespondenceType, + Discipline, + CorrespondenceSubType, + ]), ], providers: [DocumentNumberingService], - exports: [DocumentNumberingService], // Export ให้คนอื่นเรียกใช้ + exports: [DocumentNumberingService], }) export class DocumentNumberingModule {} diff --git a/backend/src/modules/document-numbering/document-numbering.service.ts b/backend/src/modules/document-numbering/document-numbering.service.ts index 81747c7..ff6f72f 100644 --- a/backend/src/modules/document-numbering/document-numbering.service.ts +++ b/backend/src/modules/document-numbering/document-numbering.service.ts @@ -1,17 +1,36 @@ +// File: src/modules/document-numbering/document-numbering.service.ts import { Injectable, OnModuleInit, OnModuleDestroy, InternalServerErrorException, + NotFoundException, Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, OptimisticLockVersionMismatchError } from 'typeorm'; +import { + Repository, + EntityManager, + OptimisticLockVersionMismatchError, +} from 'typeorm'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; import Redlock from 'redlock'; -import { DocumentNumberCounter } from './entities/document-number-counter.entity.js'; -import { DocumentNumberFormat } from './entities/document-number-format.entity.js'; + +// Entities +import { DocumentNumberCounter } from './entities/document-number-counter.entity'; +import { DocumentNumberFormat } from './entities/document-number-format.entity'; +import { Project } from '../project/entities/project.entity'; // สมมติ path +import { Organization } from '../project/entities/organization.entity'; +import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; +import { Discipline } from '../master/entities/discipline.entity'; +import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity'; + +// Interfaces +import { + GenerateNumberContext, + DecodedTokens, +} from './interfaces/document-numbering.interface.js'; @Injectable() export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { @@ -24,25 +43,39 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { private counterRepo: Repository, @InjectRepository(DocumentNumberFormat) private formatRepo: Repository, + + // Inject Repositories สำหรับดึง Code มาทำ Token Replacement + @InjectRepository(Project) private projectRepo: Repository, + @InjectRepository(Organization) private orgRepo: Repository, + @InjectRepository(CorrespondenceType) + private typeRepo: Repository, + @InjectRepository(Discipline) + private disciplineRepo: Repository, + @InjectRepository(CorrespondenceSubType) + private subTypeRepo: Repository, + private configService: ConfigService, ) {} - // 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด onModuleInit() { - this.redisClient = new Redis({ - host: this.configService.get('REDIS_HOST'), - port: this.configService.get('REDIS_PORT'), - password: this.configService.get('REDIS_PASSWORD'), - }); + // 1. Setup Redis Connection & Redlock + const host = this.configService.get('REDIS_HOST', 'localhost'); + const port = this.configService.get('REDIS_PORT', 6379); + const password = this.configService.get('REDIS_PASSWORD'); + this.redisClient = new Redis({ host, port, password }); + + // Config Redlock สำหรับ Distributed Lock this.redlock = new Redlock([this.redisClient], { driftFactor: 0.01, - retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ - retryDelay: 200, // รอ 200ms ก่อนลองใหม่ + retryCount: 10, // Retry 10 ครั้ง + retryDelay: 200, // รอ 200ms ต่อครั้ง retryJitter: 200, }); - this.logger.log('Redis & Redlock initialized for Document Numbering'); + this.logger.log( + `Document Numbering Service initialized (Redis: ${host}:${port})`, + ); } onModuleDestroy() { @@ -50,115 +83,192 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { } /** - * ฟังก์ชันหลักสำหรับขอเลขที่เอกสารถัดไป - * @param projectId ID โครงการ - * @param orgId ID องค์กรผู้ส่ง - * @param typeId ID ประเภทเอกสาร - * @param year ปีปัจจุบัน (ค.ศ.) - * @param replacements ค่าที่จะเอาไปแทนที่ใน Template (เช่น { ORG_CODE: 'TEAM' }) + * สร้างเลขที่เอกสารใหม่ (Thread-Safe & Gap-free) */ - async generateNextNumber( - projectId: number, - orgId: number, - typeId: number, - year: number, - replacements: Record = {}, - ): Promise { - const resourceKey = `doc_num:${projectId}:${typeId}:${year}`; - const ttl = 5000; // Lock จะหมดอายุใน 5 วินาที (ป้องกัน Deadlock) + async generateNextNumber(ctx: GenerateNumberContext): Promise { + const year = ctx.year || new Date().getFullYear(); + const disciplineId = ctx.disciplineId || 0; + + // 1. ดึงข้อมูล Master Data มาเตรียมไว้ (Tokens) นอก Lock เพื่อ Performance + const tokens = await this.resolveTokens(ctx, year); + + // 2. ดึง Format Template + const formatTemplate = await this.getFormatTemplate( + ctx.projectId, + ctx.typeId, + ); + + // 3. สร้าง Resource Key สำหรับ Lock (ละเอียดถึงระดับ Discipline) + // Key: doc_num:{projectId}:{typeId}:{disciplineId}:{year} + const resourceKey = `doc_num:${ctx.projectId}:${ctx.typeId}:${disciplineId}:${year}`; + const lockTtl = 5000; // 5 วินาที let lock; try { - // 🔒 Step 1: Redis Lock (Distributed Lock) - // ป้องกันไม่ให้ Process อื่นเข้ามายุ่งกับ Counter ตัวนี้พร้อมกัน - lock = await this.redlock.acquire([resourceKey], ttl); + // 🔒 LAYER 1: Acquire Redis Lock + lock = await this.redlock.acquire([resourceKey], lockTtl); - // 🔄 Step 2: Optimistic Locking Loop (Safety Net) - // เผื่อ Redis Lock หลุด หรือมีคนแทรกได้จริงๆ DB จะช่วยกันไว้อีกชั้น + // 🔄 LAYER 2: Optimistic Lock Loop const maxRetries = 3; for (let i = 0; i < maxRetries; i++) { try { - // 2.1 ดึง Counter ปัจจุบัน + // A. ดึง Counter ปัจจุบัน let counter = await this.counterRepo.findOne({ - where: { projectId, originatorId: orgId, typeId, year }, + where: { + projectId: ctx.projectId, + originatorId: ctx.originatorId, + typeId: ctx.typeId, + disciplineId: disciplineId, + year: year, + }, }); - // ถ้ายังไม่มี ให้สร้างใหม่ (เริ่มที่ 0) + // B. ถ้ายังไม่มี ให้เริ่มใหม่ที่ 0 if (!counter) { counter = this.counterRepo.create({ - projectId, - originatorId: orgId, - typeId, - year, + projectId: ctx.projectId, + originatorId: ctx.originatorId, + typeId: ctx.typeId, + disciplineId: disciplineId, + year: year, lastNumber: 0, }); } - // 2.2 บวกเลข + // C. Increment Sequence counter.lastNumber += 1; - // 2.3 บันทึก (จุดนี้ TypeORM จะเช็ค Version ให้เอง) + // D. Save (TypeORM จะเช็ค version column ตรงนี้) await this.counterRepo.save(counter); - // 2.4 ถ้าบันทึกผ่าน -> สร้าง String ตาม Format - return await this.formatNumber( - projectId, - typeId, - counter.lastNumber, - replacements, - ); + // E. Format Result + return this.replaceTokens(formatTemplate, tokens, counter.lastNumber); } catch (err) { - // ถ้า Version ชนกัน (Optimistic Lock Error) ให้วนลูปทำใหม่ + // ถ้า Version ไม่ตรง (มีคนแทรกได้ในเสี้ยววินาที) ให้ Retry if (err instanceof OptimisticLockVersionMismatchError) { this.logger.warn( - `Optimistic Lock Hit! Retrying... (${i + 1}/${maxRetries})`, + `Optimistic Lock Collision for ${resourceKey}. Retrying...`, ); continue; } - throw err; // ถ้าเป็น Error อื่น ให้โยนออกไปเลย + throw err; } } throw new InternalServerErrorException( - 'Failed to generate document number after retries', + 'Failed to generate document number after retries.', ); - } catch (err) { - this.logger.error('Error generating document number', err); - throw err; + } catch (error) { + this.logger.error(`Error generating number for ${resourceKey}`, error); + throw error; } finally { - // 🔓 Step 3: Release Redis Lock เสมอ (ไม่ว่าจะสำเร็จหรือล้มเหลว) + // 🔓 Release Lock if (lock) { - await lock.release().catch(() => {}); // ignore error if lock expired + await lock.release().catch(() => {}); } } } - // Helper: แปลงเลขเป็น String ตาม Template (เช่น {ORG}-{SEQ:004}) - private async formatNumber( + /** + * Helper: ดึงข้อมูล Code ต่างๆ จาก ID เพื่อนำมาแทนที่ใน Template + */ + private async resolveTokens( + ctx: GenerateNumberContext, + year: number, + ): Promise { + const [project, org, type] = await Promise.all([ + this.projectRepo.findOne({ where: { id: ctx.projectId } }), + this.orgRepo.findOne({ where: { id: ctx.originatorId } }), + this.typeRepo.findOne({ where: { id: ctx.typeId } }), + ]); + + if (!project || !org || !type) { + throw new NotFoundException('Project, Organization, or Type not found'); + } + + let disciplineCode = '000'; + if (ctx.disciplineId) { + const discipline = await this.disciplineRepo.findOne({ + where: { id: ctx.disciplineId }, + }); + if (discipline) disciplineCode = discipline.disciplineCode; + } + + let subTypeCode = '00'; + let subTypeNumber = '00'; + if (ctx.subTypeId) { + const subType = await this.subTypeRepo.findOne({ + where: { id: ctx.subTypeId }, + }); + if (subType) { + subTypeCode = subType.subTypeCode; + subTypeNumber = subType.subTypeNumber || '00'; + } + } + + // Convert Christian Year to Buddhist Year if needed (Req usually uses Christian, but prepared logic) + // ใน Req 6B ตัวอย่างใช้ 2568 (พ.ศ.) ดังนั้นต้องแปลง + const yearTh = (year + 543).toString(); + + return { + projectCode: project.projectCode, + orgCode: org.organizationCode, + typeCode: type.typeCode, + disciplineCode, + subTypeCode, + subTypeNumber, + year: yearTh, + yearShort: yearTh.slice(-2), // 68 + }; + } + + /** + * Helper: หา Template จาก DB หรือใช้ Default + */ + private async getFormatTemplate( projectId: number, typeId: number, - seq: number, - replacements: Record, ): Promise { - // 1. หา Template const format = await this.formatRepo.findOne({ where: { projectId, correspondenceTypeId: typeId }, }); + // Default Fallback Format (ตาม Req 2.1) + return format ? format.formatTemplate : '{ORG}-{ORG}-{SEQ:4}-{YEAR}'; + } - // ถ้าไม่มี Template ให้ใช้ Default: {SEQ} - let template = format ? format.formatTemplate : '{SEQ:4}'; + /** + * Helper: แทนที่ Token ใน Template ด้วยค่าจริง + */ + private replaceTokens( + template: string, + tokens: DecodedTokens, + seq: number, + ): string { + let result = template; - // 2. แทนที่ค่าต่างๆ (ORG_CODE, TYPE_CODE, YEAR) + const replacements: Record = { + '{PROJECT}': tokens.projectCode, + '{ORG}': tokens.orgCode, + '{TYPE}': tokens.typeCode, + '{DISCIPLINE}': tokens.disciplineCode, + '{SUBTYPE}': tokens.subTypeCode, + '{SUBTYPE_NUM}': tokens.subTypeNumber, // [Req 6B] For Transmittal/RFA + '{YEAR}': tokens.year, + '{YEAR_SHORT}': tokens.yearShort, + }; + + // 1. Replace Standard Tokens for (const [key, value] of Object.entries(replacements)) { - template = template.replace(new RegExp(`{${key}}`, 'g'), value); + // ใช้ Global Replace + result = result.split(key).join(value); } - // 3. แทนที่ SEQ (รองรับรูปแบบ {SEQ:4} คือเติม 0 ข้างหน้าให้ครบ 4 หลัก) - template = template.replace(/{SEQ(?::(\d+))?}/g, (_, digits) => { - const pad = digits ? parseInt(digits, 10) : 0; - return seq.toString().padStart(pad, '0'); + // 2. Replace Sequence Token {SEQ:n} e.g., {SEQ:4} -> 0001 + result = result.replace(/{SEQ(?::(\d+))?}/g, (_, digits) => { + const padLength = digits ? parseInt(digits, 10) : 4; // Default padding 4 + return seq.toString().padStart(padLength, '0'); }); - return template; + return result; } } diff --git a/backend/src/modules/document-numbering/entities/document-number-counter.entity.ts b/backend/src/modules/document-numbering/entities/document-number-counter.entity.ts index 304f0e6..8d6f51a 100644 --- a/backend/src/modules/document-numbering/entities/document-number-counter.entity.ts +++ b/backend/src/modules/document-numbering/entities/document-number-counter.entity.ts @@ -1,8 +1,10 @@ +// File: src/modules/document-numbering/entities/document-number-counter.entity.ts import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm'; @Entity('document_number_counters') export class DocumentNumberCounter { - // Composite Primary Key (Project + Org + Type + Year) + // Composite Primary Key: Project + Org + Type + Discipline + Year + @PrimaryColumn({ name: 'project_id' }) projectId!: number; @@ -12,13 +14,18 @@ export class DocumentNumberCounter { @PrimaryColumn({ name: 'correspondence_type_id' }) typeId!: number; + // [New v1.4.4] เพิ่ม Discipline ใน Key เพื่อแยก Counter ตามสาขา + // ใช้ default 0 กรณีไม่มี discipline เพื่อความง่ายในการจัดการ Composite Key + @PrimaryColumn({ name: 'discipline_id', default: 0 }) + disciplineId!: number; + @PrimaryColumn({ name: 'current_year' }) year!: number; @Column({ name: 'last_number', default: 0 }) lastNumber!: number; - // ✨ หัวใจสำคัญของ Optimistic Lock + // ✨ หัวใจสำคัญของ Optimistic Lock (TypeORM จะเช็ค version นี้ก่อน update) @VersionColumn() version!: number; } diff --git a/backend/src/modules/document-numbering/interfaces/document-numbering.interface.ts b/backend/src/modules/document-numbering/interfaces/document-numbering.interface.ts new file mode 100644 index 0000000..9e5f745 --- /dev/null +++ b/backend/src/modules/document-numbering/interfaces/document-numbering.interface.ts @@ -0,0 +1,24 @@ +// File: src/modules/document-numbering/interfaces/document-numbering.interface.ts + +export interface GenerateNumberContext { + projectId: number; + originatorId: number; // องค์กรผู้ส่ง + typeId: number; // ประเภทเอกสาร (Correspondence Type ID) + subTypeId?: number; // (Optional) Sub Type ID (สำหรับ RFA/Transmittal) + disciplineId?: number; // (Optional) Discipline ID (สาขางาน) + year?: number; // (Optional) ถ้าไม่ส่งจะใช้ปีปัจจุบัน + + // สำหรับกรณีพิเศษที่ต้องการ Override ค่าบางอย่าง + customTokens?: Record; +} + +export interface DecodedTokens { + projectCode: string; + orgCode: string; + typeCode: string; + disciplineCode: string; + subTypeCode: string; + subTypeNumber: string; + year: string; + yearShort: string; +} diff --git a/backend/src/modules/master/entities/discipline.entity.ts b/backend/src/modules/master/entities/discipline.entity.ts new file mode 100644 index 0000000..c51225b --- /dev/null +++ b/backend/src/modules/master/entities/discipline.entity.ts @@ -0,0 +1,45 @@ +// File: src/modules/master/entities/discipline.entity.ts +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + Unique, +} from 'typeorm'; +import { Contract } from '../../project/entities/contract.entity'; // ปรับ path ตามจริง + +@Entity('disciplines') +@Unique(['contractId', 'disciplineCode']) // ป้องกันรหัสซ้ำในสัญญาเดียวกัน +export class Discipline { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'contract_id' }) + contractId!: number; + + @Column({ name: 'discipline_code', length: 10 }) + disciplineCode!: string; // เช่น GEN, STR, ARC + + @Column({ name: 'code_name_th', nullable: true }) + codeNameTh?: string; + + @Column({ name: 'code_name_en', nullable: true }) + codeNameEn?: string; + + @Column({ name: 'is_active', default: true }) + isActive!: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + // Relations + @ManyToOne(() => Contract) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; +} diff --git a/backend/src/modules/rfa/dto/create-rfa.dto.ts b/backend/src/modules/rfa/dto/create-rfa.dto.ts index a694c34..70da3e3 100644 --- a/backend/src/modules/rfa/dto/create-rfa.dto.ts +++ b/backend/src/modules/rfa/dto/create-rfa.dto.ts @@ -1,3 +1,4 @@ +// File: src/modules/rfa/dto/create-rfa.dto.ts import { IsInt, IsString, @@ -16,6 +17,10 @@ export class CreateRfaDto { @IsNotEmpty() rfaTypeId!: number; + @IsInt() + @IsOptional() + disciplineId?: number; // [Req 6B] สาขางาน (จำเป็นสำหรับการรันเลข RFA) + @IsString() @IsNotEmpty() title!: string; @@ -40,4 +45,4 @@ export class CreateRfaDto { @IsInt({ each: true }) @IsOptional() shopDrawingRevisionIds?: number[]; // Shop Drawings ที่แนบมา -} +} \ No newline at end of file diff --git a/backend/src/modules/rfa/rfa.service.ts b/backend/src/modules/rfa/rfa.service.ts index 075c17a..a8e462e 100644 --- a/backend/src/modules/rfa/rfa.service.ts +++ b/backend/src/modules/rfa/rfa.service.ts @@ -1,3 +1,5 @@ +// File: src/modules/rfa/rfa.service.ts + import { Injectable, NotFoundException, @@ -10,31 +12,31 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, In } from 'typeorm'; // Entities -import { Rfa } from './entities/rfa.entity'; -import { RfaRevision } from './entities/rfa-revision.entity'; -import { RfaItem } from './entities/rfa-item.entity'; -import { RfaType } from './entities/rfa-type.entity'; -import { RfaStatusCode } from './entities/rfa-status-code.entity'; -import { RfaApproveCode } from './entities/rfa-approve-code.entity'; -import { Correspondence } from '../correspondence/entities/correspondence.entity'; -import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity'; -import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; -import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; -import { User } from '../user/entities/user.entity'; +import { Rfa } from './entities/rfa.entity.js'; +import { RfaRevision } from './entities/rfa-revision.entity.js'; +import { RfaItem } from './entities/rfa-item.entity.js'; +import { RfaType } from './entities/rfa-type.entity.js'; +import { RfaStatusCode } from './entities/rfa-status-code.entity.js'; +import { RfaApproveCode } from './entities/rfa-approve-code.entity.js'; +import { Correspondence } from '../correspondence/entities/correspondence.entity.js'; +import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity.js'; +import { RoutingTemplate } from '../correspondence/entities/routing-template.entity.js'; +import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity.js'; +import { User } from '../user/entities/user.entity.js'; // DTOs -import { CreateRfaDto } from './dto/create-rfa.dto'; -import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; +import { CreateRfaDto } from './dto/create-rfa.dto.js'; +import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto.js'; // Interfaces & Enums -import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface'; // ตรวจสอบ path นี้ให้ตรงกับไฟล์จริง +import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface.js'; // Services -import { DocumentNumberingService } from '../document-numbering/document-numbering.service'; -import { UserService } from '../user/user.service'; -import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; -import { NotificationService } from '../notification/notification.service'; -import { SearchService } from '../search/search.service'; // Import SearchService +import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js'; +import { UserService } from '../user/user.service.js'; +import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js'; +import { NotificationService } from '../notification/notification.service.js'; +import { SearchService } from '../search/search.service.js'; @Injectable() export class RfaService { @@ -67,12 +69,9 @@ export class RfaService { private workflowEngine: WorkflowEngineService, private notificationService: NotificationService, private dataSource: DataSource, - private searchService: SearchService, // Inject + private searchService: SearchService, ) {} - /** - * สร้างเอกสาร RFA ใหม่ (Create RFA) - */ async create(createDto: CreateRfaDto, user: User) { const rfaType = await this.rfaTypeRepo.findOne({ where: { id: createDto.rfaTypeId }, @@ -103,20 +102,24 @@ export class RfaService { try { const orgCode = 'ORG'; // TODO: Fetch real ORG Code - const docNumber = await this.numberingService.generateNextNumber( - createDto.projectId, - userOrgId, - createDto.rfaTypeId, - new Date().getFullYear(), - { + + // [FIXED] เรียกใช้แบบ Object Context พร้อม disciplineId + const docNumber = await this.numberingService.generateNextNumber({ + projectId: createDto.projectId, + originatorId: userOrgId, + typeId: createDto.rfaTypeId, // RFA Type ใช้เป็น ID ในการนับเลข + disciplineId: createDto.disciplineId, // สำคัญมากสำหรับ RFA (Req 6B) + year: new Date().getFullYear(), + customTokens: { TYPE_CODE: rfaType.typeCode, ORG_CODE: orgCode, }, - ); + }); const correspondence = queryRunner.manager.create(Correspondence, { correspondenceNumber: docNumber, - correspondenceTypeId: createDto.rfaTypeId, + correspondenceTypeId: createDto.rfaTypeId, // Map RFA Type to Corr Type ID + disciplineId: createDto.disciplineId, // บันทึก Discipline projectId: createDto.projectId, originatorId: userOrgId, isInternal: false, @@ -126,6 +129,7 @@ export class RfaService { const rfa = queryRunner.manager.create(Rfa, { rfaTypeId: createDto.rfaTypeId, + disciplineId: createDto.disciplineId, // บันทึก Discipline createdBy: user.user_id, }); const savedRfa = await queryRunner.manager.save(rfa); @@ -168,7 +172,7 @@ export class RfaService { } await queryRunner.commitTransaction(); - // 🔥 Fire & Forget: ไม่ต้อง await ผลลัพธ์เพื่อความเร็ว (หรือใช้ Queue ก็ได้) + this.searchService.indexDocument({ id: savedCorr.id, type: 'correspondence', @@ -196,9 +200,7 @@ export class RfaService { } } - /** - * ดึงข้อมูล RFA รายตัว (Get One) - */ + // ... (method อื่นๆ findOne, submit, processAction คงเดิม) async findOne(id: number) { const rfa = await this.rfaRepo.findOne({ where: { id }, @@ -224,9 +226,6 @@ export class RfaService { return rfa; } - /** - * เริ่มต้นกระบวนการอนุมัติ (Submit Workflow) - */ async submit(rfaId: number, templateId: number, user: User) { const rfa = await this.findOne(rfaId); const currentRevision = rfa.revisions.find((r) => r.isCurrent); @@ -287,7 +286,6 @@ export class RfaService { }); await queryRunner.manager.save(routing); - // Notification const recipientUserId = await this.userService.findDocControlIdByOrg( firstStep.toOrganizationId, ); @@ -316,9 +314,6 @@ export class RfaService { } } - /** - * ดำเนินการอนุมัติ/ปฏิเสธ (Process Workflow Action) - */ async processAction(rfaId: number, dto: WorkflowActionDto, user: User) { const rfa = await this.findOne(rfaId); const currentRevision = rfa.revisions.find((r) => r.isCurrent); @@ -401,7 +396,6 @@ export class RfaService { result.nextStepSequence === null && dto.action !== WorkflowAction.REJECT ) { - // Completed (Approved) const approveCodeStr = dto.action === WorkflowAction.APPROVE ? '1A' : '4X'; const approveCode = await this.rfaApproveRepo.findOne({ @@ -414,7 +408,6 @@ export class RfaService { } await queryRunner.manager.save(currentRevision); } else if (dto.action === WorkflowAction.REJECT) { - // Rejected const rejectCode = await this.rfaApproveRepo.findOne({ where: { approveCode: '4X' }, }); @@ -436,4 +429,4 @@ export class RfaService { await queryRunner.release(); } } -} +} \ No newline at end of file diff --git a/backend/src/modules/transmittal/transmittal.service.ts b/backend/src/modules/transmittal/transmittal.service.ts index 39b4fe9..325f51e 100644 --- a/backend/src/modules/transmittal/transmittal.service.ts +++ b/backend/src/modules/transmittal/transmittal.service.ts @@ -1,3 +1,5 @@ +// File: src/modules/transmittal/transmittal.service.ts + import { Injectable, NotFoundException, @@ -6,13 +8,13 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, In } from 'typeorm'; -import { Transmittal } from './entities/transmittal.entity'; -import { TransmittalItem } from './entities/transmittal-item.entity'; -import { Correspondence } from '../correspondence/entities/correspondence.entity'; -import { CreateTransmittalDto } from './dto/create-transmittal.dto'; // ต้องสร้าง DTO -import { User } from '../user/entities/user.entity'; -import { DocumentNumberingService } from '../document-numbering/document-numbering.service'; -import { SearchService } from '../search/search.service'; // Import SearchService +import { Transmittal } from './entities/transmittal.entity.js'; +import { TransmittalItem } from './entities/transmittal-item.entity.js'; +import { Correspondence } from '../correspondence/entities/correspondence.entity.js'; +import { CreateTransmittalDto } from './dto/create-transmittal.dto.js'; +import { User } from '../user/entities/user.entity.js'; +import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js'; +import { SearchService } from '../search/search.service.js'; @Injectable() export class TransmittalService { @@ -25,47 +27,47 @@ export class TransmittalService { private correspondenceRepo: Repository, private numberingService: DocumentNumberingService, private dataSource: DataSource, - private searchService: SearchService, // Inject + private searchService: SearchService, ) {} async create(createDto: CreateTransmittalDto, user: User) { - // ✅ FIX: ตรวจสอบว่า User มีสังกัดองค์กรหรือไม่ if (!user.primaryOrganizationId) { throw new BadRequestException( 'User must belong to an organization to create documents', ); } - const userOrgId = user.primaryOrganizationId; // TypeScript จะรู้ว่าเป็น number แล้ว + const userOrgId = user.primaryOrganizationId; const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { - // 1. Generate Document Number - const transmittalTypeId = 3; // TODO: ควรดึง ID จริงจาก DB หรือ Config + const transmittalTypeId = 3; // TODO: ดึง ID จริงจาก DB หรือ Config const orgCode = 'ORG'; // TODO: Fetch real ORG Code - const docNumber = await this.numberingService.generateNextNumber( - createDto.projectId, - userOrgId, // ✅ ส่งค่าที่เช็คแล้ว - transmittalTypeId, - new Date().getFullYear(), - { TYPE_CODE: 'TR', ORG_CODE: orgCode }, - ); + // [FIXED] เรียกใช้แบบ Object Context + const docNumber = await this.numberingService.generateNextNumber({ + projectId: createDto.projectId, + originatorId: userOrgId, + typeId: transmittalTypeId, + year: new Date().getFullYear(), + customTokens: { + TYPE_CODE: 'TR', + ORG_CODE: orgCode, + }, + }); - // 2. Create Correspondence (Header) const correspondence = queryRunner.manager.create(Correspondence, { correspondenceNumber: docNumber, correspondenceTypeId: transmittalTypeId, projectId: createDto.projectId, - originatorId: userOrgId, // ✅ ส่งค่าที่เช็คแล้ว + originatorId: userOrgId, isInternal: false, createdBy: user.user_id, }); const savedCorr = await queryRunner.manager.save(correspondence); - // 3. Create Transmittal (Detail) const transmittal = queryRunner.manager.create(Transmittal, { correspondenceId: savedCorr.id, purpose: createDto.purpose, @@ -73,7 +75,6 @@ export class TransmittalService { }); await queryRunner.manager.save(transmittal); - // 4. Link Items (Documents being sent) if (createDto.itemIds && createDto.itemIds.length > 0) { const items = createDto.itemIds.map((itemId) => queryRunner.manager.create(TransmittalItem, { @@ -94,4 +95,4 @@ export class TransmittalService { await queryRunner.release(); } } -} +} \ No newline at end of file