// File: src/modules/rfa/rfa.service.ts import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException, Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, Repository } from 'typeorm'; // Entities import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity'; import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; import { User } from '../user/entities/user.entity'; import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaItem } from './entities/rfa-item.entity'; import { RfaRevision } from './entities/rfa-revision.entity'; import { RfaStatusCode } from './entities/rfa-status-code.entity'; import { RfaType } from './entities/rfa-type.entity'; import { Rfa } from './entities/rfa.entity'; // DTOs import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; import { CreateRfaDto } from './dto/create-rfa.dto'; // Interfaces & Enums import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface'; // Services import { DocumentNumberingService } from '../document-numbering/document-numbering.service'; import { NotificationService } from '../notification/notification.service'; import { SearchService } from '../search/search.service'; import { UserService } from '../user/user.service'; import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; @Injectable() export class RfaService { private readonly logger = new Logger(RfaService.name); constructor( @InjectRepository(Rfa) private rfaRepo: Repository, @InjectRepository(RfaRevision) private rfaRevisionRepo: Repository, @InjectRepository(RfaItem) private rfaItemRepo: Repository, @InjectRepository(Correspondence) private correspondenceRepo: Repository, @InjectRepository(RfaType) private rfaTypeRepo: Repository, @InjectRepository(RfaStatusCode) private rfaStatusRepo: Repository, @InjectRepository(RfaApproveCode) private rfaApproveRepo: Repository, @InjectRepository(ShopDrawingRevision) private shopDrawingRevRepo: Repository, @InjectRepository(CorrespondenceRouting) private routingRepo: Repository, @InjectRepository(RoutingTemplate) private templateRepo: Repository, @InjectRepository(RoutingTemplateStep) private templateStepRepo: Repository, private numberingService: DocumentNumberingService, private userService: UserService, private workflowEngine: WorkflowEngineService, private notificationService: NotificationService, private dataSource: DataSource, private searchService: SearchService ) {} async create(createDto: CreateRfaDto, user: User) { const rfaType = await this.rfaTypeRepo.findOne({ where: { id: createDto.rfaTypeId }, }); if (!rfaType) throw new NotFoundException('RFA Type not found'); const statusDraft = await this.rfaStatusRepo.findOne({ where: { statusCode: 'DFT' }, }); if (!statusDraft) { throw new InternalServerErrorException( 'Status DFT (Draft) not found in Master Data' ); } // Determine User Organization let userOrgId = user.primaryOrganizationId; if (!userOrgId) { const fullUser = await this.userService.findOne(user.user_id); if (fullUser) userOrgId = fullUser.primaryOrganizationId; } if (!userOrgId) { throw new BadRequestException('User must belong to an organization'); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Org Service if needed // [UPDATED] Generate Document Number with Discipline const docNumber = await this.numberingService.generateNextNumber({ projectId: createDto.projectId, originatorId: userOrgId, typeId: createDto.rfaTypeId, disciplineId: createDto.disciplineId ?? 0, // ✅ ส่ง disciplineId ไปด้วย (0 ถ้าไม่มี) year: new Date().getFullYear(), customTokens: { TYPE_CODE: rfaType.typeCode, ORG_CODE: orgCode, }, }); // 1. Create Correspondence Record const correspondence = queryRunner.manager.create(Correspondence, { correspondenceNumber: docNumber, correspondenceTypeId: createDto.rfaTypeId, projectId: createDto.projectId, originatorId: userOrgId, isInternal: false, createdBy: user.user_id, disciplineId: createDto.disciplineId, // ✅ Add disciplineId }); const savedCorr = await queryRunner.manager.save(correspondence); // 2. Create Rfa Master Record const rfa = queryRunner.manager.create(Rfa, { rfaTypeId: createDto.rfaTypeId, createdBy: user.user_id, disciplineId: createDto.disciplineId, // ✅ Add disciplineId }); const savedRfa = await queryRunner.manager.save(rfa); // 3. Create First Revision (Draft) const rfaRevision = queryRunner.manager.create(RfaRevision, { correspondenceId: savedCorr.id, rfaId: savedRfa.id, revisionNumber: 0, revisionLabel: '0', isCurrent: true, rfaStatusCodeId: statusDraft.id, title: createDto.title, description: createDto.description, documentDate: createDto.documentDate ? new Date(createDto.documentDate) : new Date(), createdBy: user.user_id, details: createDto.details, schemaVersion: 1, }); const savedRevision = await queryRunner.manager.save(rfaRevision); // 4. Link Shop Drawings if ( createDto.shopDrawingRevisionIds && createDto.shopDrawingRevisionIds.length > 0 ) { const shopDrawings = await this.shopDrawingRevRepo.findBy({ id: In(createDto.shopDrawingRevisionIds), }); if (shopDrawings.length !== createDto.shopDrawingRevisionIds.length) { throw new NotFoundException('Some Shop Drawing Revisions not found'); } const rfaItems = shopDrawings.map((sd) => queryRunner.manager.create(RfaItem, { rfaRevisionId: savedRevision.id, // Correctly link to RfaRevision shopDrawingRevisionId: sd.id, }) ); await queryRunner.manager.save(rfaItems); } await queryRunner.commitTransaction(); // [NEW V1.5.1] Start Unified Workflow Instance try { const workflowCode = `RFA_${rfaType.typeCode}`; // e.g., RFA_GEN await this.workflowEngine.createInstance( workflowCode, 'rfa', savedRfa.id.toString(), { projectId: createDto.projectId, originatorId: userOrgId, disciplineId: createDto.disciplineId, initiatorId: user.user_id, } ); } catch (error) { this.logger.warn( `Workflow not started for ${docNumber}: ${(error as Error).message}` ); } // Indexing for Search this.searchService .indexDocument({ id: savedCorr.id, type: 'rfa', docNumber: docNumber, title: createDto.title, description: createDto.description, status: 'DRAFT', projectId: createDto.projectId, createdAt: new Date(), }) .catch((err) => this.logger.error(`Indexing failed: ${err}`)); return { ...savedRfa, correspondenceNumber: docNumber, currentRevision: savedRevision, }; } catch (err) { await queryRunner.rollbackTransaction(); this.logger.error(`Failed to create RFA: ${(err as Error).message}`); throw err; } finally { await queryRunner.release(); } } // ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ... async findAll(query: any) { const { page = 1, limit = 20, projectId, status, search } = query; const skip = (page - 1) * limit; // Fix: Start query from Rfa entity instead of Correspondence, // because Correspondence has no 'rfas' relation. // [Force Rebuild] const queryBuilder = this.rfaRepo .createQueryBuilder('rfa') .leftJoinAndSelect('rfa.revisions', 'rev') .leftJoinAndSelect('rev.correspondence', 'corr') .leftJoinAndSelect('corr.project', 'project') .leftJoinAndSelect('rfa.discipline', 'discipline') .leftJoinAndSelect('rev.statusCode', 'status') .leftJoinAndSelect('rev.items', 'items') .leftJoinAndSelect('items.shopDrawingRevision', 'sdRev') .leftJoinAndSelect('sdRev.attachments', 'attachments'); // Filter by Revision Status (from query param 'revisionStatus') const revStatus = query.revisionStatus || 'CURRENT'; if (revStatus === 'CURRENT') { queryBuilder.where('rev.isCurrent = :isCurrent', { isCurrent: true }); } else if (revStatus === 'OLD') { queryBuilder.where('rev.isCurrent = :isCurrent', { isCurrent: false }); } // If 'ALL', no filter if (projectId) { queryBuilder.andWhere('corr.projectId = :projectId', { projectId }); } if (status) { queryBuilder.andWhere('status.statusCode = :status', { status }); } if (search) { queryBuilder.andWhere( '(corr.correspondenceNumber LIKE :search OR rev.title LIKE :search)', { search: `%${search}%` } ); } const [items, total] = await queryBuilder .orderBy('corr.createdAt', 'DESC') .skip(skip) .take(limit) .getManyAndCount(); this.logger.log( `[DEBUG] RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}` ); return { data: items, meta: { total, page, limit, totalPages: Math.ceil(total / limit), }, }; } async findOne(id: number) { const rfa = await this.rfaRepo.findOne({ where: { id }, relations: [ 'rfaType', 'revisions', 'revisions.statusCode', 'revisions.approveCode', 'revisions.correspondence', 'revisions.items', 'revisions.items.shopDrawingRevision', 'revisions.items.shopDrawingRevision.shopDrawing', ], order: { revisions: { revisionNumber: 'DESC' }, }, }); if (!rfa) { throw new NotFoundException(`RFA ID ${id} not found`); } return rfa; } async submit(rfaId: number, templateId: number, user: User) { const rfa = await this.findOne(rfaId); const currentRevision = rfa.revisions.find((r) => r.isCurrent); if (!currentRevision) throw new NotFoundException('Current revision not found'); if (currentRevision.statusCode.statusCode !== 'DFT') { throw new BadRequestException('Only DRAFT documents can be submitted'); } const template = await this.templateRepo.findOne({ where: { id: templateId }, // relations: ['steps'], // Deprecated relation removed }); if (!template) { throw new BadRequestException('Invalid routing template'); } // Manual fetch of steps const steps = await this.templateStepRepo.find({ where: { templateId: template.id }, order: { sequence: 'ASC' }, }); if (steps.length === 0) { throw new BadRequestException('Routing template has no steps'); } const statusForApprove = await this.rfaStatusRepo.findOne({ where: { statusCode: 'FAP' }, }); if (!statusForApprove) throw new InternalServerErrorException('Status FAP not found'); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Update Revision Status currentRevision.rfaStatusCodeId = statusForApprove.id; currentRevision.issuedDate = new Date(); await queryRunner.manager.save(currentRevision); // Create First Routing Step const firstStep = steps[0]; const routing = queryRunner.manager.create(CorrespondenceRouting, { correspondenceId: currentRevision.correspondenceId, templateId: template.id, sequence: 1, 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, processedAt: new Date(), }); await queryRunner.manager.save(routing); // Notify const recipientUserId = await this.userService.findDocControlIdByOrg( firstStep.toOrganizationId ); if (recipientUserId) { await this.notificationService.send({ userId: recipientUserId, title: `RFA Submitted: ${currentRevision.title}`, message: `RFA ${currentRevision.correspondence.correspondenceNumber} submitted for approval.`, type: 'SYSTEM', entityType: 'rfa', entityId: rfa.id, }); } await queryRunner.commitTransaction(); return { message: 'RFA Submitted successfully', routing }; } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } async processAction(rfaId: number, dto: WorkflowActionDto, user: User) { // Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB // ใช้ this.workflowEngine.processAction (Legacy Support) // ... (สามารถใช้ Code เดิมจากที่คุณแนบมาได้เลย เพราะ Logic ถูกต้องแล้วสำหรับการใช้ CorrespondenceRouting) ... const rfa = await this.findOne(rfaId); const currentRevision = rfa.revisions.find((r) => r.isCurrent); if (!currentRevision) throw new NotFoundException('Current revision not found'); const currentRouting = await this.routingRepo.findOne({ where: { correspondenceId: currentRevision.correspondenceId, status: 'SENT', }, order: { sequence: 'DESC' }, relations: ['toOrganization'], }); if (!currentRouting) throw new BadRequestException('No active workflow step found'); if (currentRouting.toOrganizationId !== user.primaryOrganizationId) { throw new ForbiddenException( 'You are not authorized to process this step' ); } const template = await this.templateRepo.findOne({ where: { id: currentRouting.templateId }, // relations: ['steps'], }); if (!template) throw new InternalServerErrorException('Template not found'); // Manual fetch steps const steps = await this.templateStepRepo.find({ where: { templateId: template.id }, order: { sequence: 'ASC' }, }); if (steps.length === 0) throw new InternalServerErrorException('Template steps not found'); // Call Engine to calculate next step const result = this.workflowEngine.processAction( currentRouting.sequence, steps.length, dto.action, dto.returnToSequence ); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Update current routing currentRouting.status = dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED'; currentRouting.processedByUserId = user.user_id; currentRouting.processedAt = new Date(); currentRouting.comments = dto.comments; await queryRunner.manager.save(currentRouting); // Create next routing if available if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) { const nextStep = steps.find( (s) => s.sequence === result.nextStepSequence ); if (nextStep) { const nextRouting = queryRunner.manager.create( CorrespondenceRouting, { correspondenceId: currentRevision.correspondenceId, templateId: template.id, sequence: result.nextStepSequence, fromOrganizationId: user.primaryOrganizationId, toOrganizationId: nextStep.toOrganizationId, stepPurpose: nextStep.stepPurpose, status: 'SENT', dueDate: new Date( Date.now() + (nextStep.expectedDays || 7) * 24 * 60 * 60 * 1000 ), } ); await queryRunner.manager.save(nextRouting); } } else if (result.nextStepSequence === null) { // Workflow Ended (Completed or Rejected) // Update RFA Status (Approved/Rejected Code) if (dto.action !== WorkflowAction.REJECT) { const approveCode = await this.rfaApproveRepo.findOne({ where: { approveCode: dto.action === WorkflowAction.APPROVE ? '1A' : '4X', }, }); // Logic Map Code อย่างง่าย if (approveCode) { currentRevision.rfaApproveCodeId = approveCode.id; currentRevision.approvedDate = new Date(); } } else { const rejectCode = await this.rfaApproveRepo.findOne({ where: { approveCode: '4X' }, }); if (rejectCode) currentRevision.rfaApproveCodeId = rejectCode.id; } await queryRunner.manager.save(currentRevision); } await queryRunner.commitTransaction(); return { message: 'Action processed', result }; } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } }