import { Injectable, Logger } from '@nestjs/common'; import { NotFoundException, PermissionException, SystemException, ValidationException, } from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Transmittal } from './entities/transmittal.entity'; import { TransmittalItem } from './entities/transmittal-item.entity'; import { CreateTransmittalDto, TransmittalItemDto, } from './dto/create-transmittal.dto'; import { SearchTransmittalDto } from './dto/search-transmittal.dto'; import { User } from '../user/entities/user.entity'; import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; import { UuidResolverService } from '../../common/services/uuid-resolver.service'; import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity'; import { UserService } from '../user/user.service'; import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; @Injectable() export class TransmittalService { private readonly logger = new Logger(TransmittalService.name); private async hasSystemManageAllPermission(userId: number): Promise { const permissions = await this.userService.getUserPermissions(userId); return permissions.includes('system.manage_all'); } constructor( @InjectRepository(Transmittal) private transmittalRepo: Repository, @InjectRepository(TransmittalItem) private itemRepo: Repository, @InjectRepository(CorrespondenceType) private typeRepo: Repository, @InjectRepository(CorrespondenceStatus) private statusRepo: Repository, @InjectRepository(CorrespondenceRevision) private revisionRepo: Repository, private numberingService: DocumentNumberingService, private dataSource: DataSource, private uuidResolver: UuidResolverService, private userService: UserService, private workflowEngine: WorkflowEngineService ) {} async create( createDto: CreateTransmittalDto, user: User ): Promise { // 1. Get Transmittal Type (Assuming Code '901' or 'TRN') const type = await this.typeRepo.findOne({ where: { typeCode: 'TRN' }, // Adjust code as per Master Data }); if (!type) throw new NotFoundException('Transmittal Type (TRN)'); const statusDraft = await this.statusRepo.findOne({ where: { statusCode: 'DRAFT' }, }); if (!statusDraft) throw new SystemException('Status DRAFT not found in Master Data'); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); let userOrgId = user.primaryOrganizationId; if (!userOrgId) { const fullUser = await this.userService.findOne(user.user_id); if (fullUser) { userOrgId = fullUser.primaryOrganizationId; } } const resolvedOriginatorId = createDto.originatorId ? await this.uuidResolver.resolveOrganizationId(createDto.originatorId) : undefined; if (resolvedOriginatorId && resolvedOriginatorId !== userOrgId) { const canManageAll = await this.hasSystemManageAllPermission( user.user_id ); if (!canManageAll) { throw new PermissionException( 'transmittal', 'create on behalf of other organization' ); } userOrgId = resolvedOriginatorId; } if (!userOrgId) { throw new ValidationException( 'User must belong to an organization to create a transmittal' ); } try { // ADR-019: Resolve UUID→INT for projectId const internalProjectId = await this.uuidResolver.resolveProjectId( createDto.projectId ); // 2. Generate Number const docNumber = await this.numberingService.generateNextNumber({ projectId: internalProjectId, originatorOrganizationId: userOrgId, typeId: type.id, year: new Date().getFullYear(), customTokens: { TYPE_CODE: type.typeCode, ORG_CODE: 'ORG', // TODO: Fetch real ORG Code }, }); // 3. Create Correspondence (Parent) const correspondence = queryRunner.manager.create(Correspondence, { correspondenceNumber: docNumber.number, correspondenceTypeId: type.id, projectId: internalProjectId, originatorId: userOrgId, isInternal: false, createdBy: user.user_id, }); const savedCorr = await queryRunner.manager.save(correspondence); // 4. Create Revision (Draft) const revision = queryRunner.manager.create(CorrespondenceRevision, { correspondenceId: savedCorr.id, revisionNumber: 0, revisionLabel: '0', isCurrent: true, statusId: statusDraft.id, title: createDto.subject, createdBy: user.user_id, }); await queryRunner.manager.save(revision); // ADR-019: Resolve recipientOrganizationId UUID→INT and create recipient record const internalRecipientOrgId = await this.uuidResolver.resolveOrganizationId( createDto.recipientOrganizationId ); const recipient = queryRunner.manager.create(CorrespondenceRecipient, { correspondenceId: savedCorr.id, recipientOrganizationId: internalRecipientOrgId, recipientType: 'TO', }); await queryRunner.manager.save(recipient); // 5. Create Transmittal const transmittal = queryRunner.manager.create(Transmittal, { correspondenceId: savedCorr.id, purpose: createDto.purpose || 'FOR_REVIEW', remarks: createDto.remarks, }); const savedTransmittal = await queryRunner.manager.save(transmittal); // 6. Create Items if (createDto.items && createDto.items.length > 0) { const items = createDto.items.map((item: TransmittalItemDto) => queryRunner.manager.create(TransmittalItem, { transmittalId: savedCorr.id, itemCorrespondenceId: item.itemId, // Direct mapping forced by Schema quantity: 1, // Default, not in DTO remarks: item.description, }) ); await queryRunner.manager.save(items); } await queryRunner.commitTransaction(); return { ...savedTransmittal, correspondence: savedCorr, }; } catch (err: unknown) { await queryRunner.rollbackTransaction(); this.logger.error( `Failed to create transmittal: ${(err as Error).message}` ); throw err; } finally { await queryRunner.release(); } } /** * ADR-019: Find Transmittal by parent Correspondence publicId (public identifier). * v1.8.7: Exposes workflowInstanceId, workflowState, availableActions via WorkflowEngineService */ async findOneByUuid(publicId: string): Promise< Transmittal & { workflowInstanceId?: string; workflowState?: string; availableActions?: string[]; } > { const correspondence = await this.dataSource.manager.findOne( Correspondence, { where: { publicId }, select: ['id'] } ); if (!correspondence) { throw new NotFoundException( `Transmittal with publicId ${publicId} not found` ); } const transmittal = await this.findOne(correspondence.id); // v1.8.7: ดึง Workflow Instance สำหรับ Transmittal นี้ (nullable — Draft ไม่มี Instance) const wfInstance = await this.workflowEngine.getInstanceByEntity( 'transmittal', correspondence.id.toString() ); return { ...transmittal, workflowInstanceId: wfInstance?.id, workflowState: wfInstance?.currentState, availableActions: wfInstance?.availableActions ?? [], }; } async findOne(id: number): Promise { const transmittal = await this.transmittalRepo.findOne({ where: { correspondenceId: id }, relations: ['correspondence', 'correspondence.revisions', 'items'], }); if (!transmittal) throw new NotFoundException(`Transmittal ID ${id} not found`); return transmittal; } /** * Submit Transmittal — ตรวจสอบ EC-RFA-004 ก่อนเริ่ม Workflow (v1.8.7) * EC-RFA-004: ทุก item ต้องไม่อยู่ใน DRAFT ก่อน Submit */ async submit( uuid: string, user: User ): Promise<{ instanceId: string; currentState: string }> { const correspondence = await this.dataSource.manager.findOne( Correspondence, { where: { publicId: uuid }, select: ['id', 'correspondenceNumber'] } ); if (!correspondence) throw new NotFoundException(`Transmittal publicId ${uuid}`); const transmittal = await this.transmittalRepo.findOne({ where: { correspondenceId: correspondence.id }, relations: ['items'], }); if (!transmittal) throw new NotFoundException('Transmittal', uuid); // EC-RFA-004: ตรวจสอบว่า item ทุกชิ้นไม่อยู่ใน DRAFT if (transmittal.items && transmittal.items.length > 0) { const itemCorrIds = transmittal.items.map((i) => i.itemCorrespondenceId); const draftRevisions = await this.revisionRepo .createQueryBuilder('rev') .innerJoin('rev.status', 'status') .where('rev.correspondenceId IN (:...ids)', { ids: itemCorrIds }) .andWhere('rev.isCurrent = :isCurrent', { isCurrent: true }) .andWhere('status.statusCode = :code', { code: 'DRAFT' }) .leftJoinAndSelect('rev.correspondence', 'corr') .getMany(); if (draftRevisions.length > 0) { const draftDocNo = draftRevisions[0]?.correspondence?.correspondenceNumber ?? 'Unknown'; throw new ValidationException( `RFA ${draftDocNo} ยังอยู่ใน Draft กรุณา Submit ก่อน` ); } } // เริ่ม Workflow Instance สำหรับ Transmittal const statusDraft = await this.statusRepo.findOne({ where: { statusCode: 'DRAFT' }, }); const instance = await this.workflowEngine.createInstance( 'TRANSMITTAL_FLOW_V1', 'transmittal', correspondence.id.toString(), { ownerId: user.user_id } ); const result = await this.workflowEngine.processTransition( instance.id, 'SUBMIT', user.user_id, 'Transmittal Submitted' ); // Sync สถานะกลับที่ Correspondence Revision if (statusDraft) { const revision = await this.revisionRepo.findOne({ where: { correspondenceId: correspondence.id, isCurrent: true }, }); if (revision) { const submittedStatus = await this.statusRepo.findOne({ where: { statusCode: 'SUBMITTED' }, }); if (submittedStatus) { revision.statusId = submittedStatus.id; await this.revisionRepo.save(revision); } } } this.logger.log(`Transmittal ${uuid} submitted — instance ${instance.id}`); return { instanceId: instance.id, currentState: result.nextState }; } async findAll(query: SearchTransmittalDto) { const { page = 1, limit = 20, projectId, search } = query; const skip = ((page ?? 1) - 1) * (limit ?? 20); const queryBuilder = this.transmittalRepo .createQueryBuilder('transmittal') .innerJoinAndSelect('transmittal.correspondence', 'correspondence') .leftJoinAndSelect( 'correspondence.revisions', 'revision', 'revision.isCurrent = :isCurrent', { isCurrent: true } ) .leftJoinAndSelect('transmittal.items', 'items') .leftJoinAndSelect('items.itemCorrespondence', 'itemCorrespondence'); if (projectId) { queryBuilder.andWhere('correspondence.projectId = :projectId', { projectId, }); } // B3: purpose filter (EC-RFA-004 aligned) if (query.purpose) { queryBuilder.andWhere('transmittal.purpose = :purpose', { purpose: query.purpose, }); } if (search) { queryBuilder.andWhere( '(correspondence.correspondenceNumber LIKE :search OR revision.title LIKE :search)', { search: `%${search}%` } ); } const [items, total] = await queryBuilder .orderBy('correspondence.createdAt', 'DESC') .skip(skip) .take(limit) .getManyAndCount(); // ADR-019: Map correspondence.publicId to top level for frontend convenience const mappedItems = items.map((t) => ({ ...t, publicId: t.correspondence?.publicId, })); return { data: mappedItems, meta: { total, page, limit, totalPages: Math.ceil(total / limit), }, }; } }