Files
lcbp3/backend/src/modules/transmittal/transmittal.service.ts
T

382 lines
13 KiB
TypeScript

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<boolean> {
const permissions = await this.userService.getUserPermissions(userId);
return permissions.includes('system.manage_all');
}
constructor(
@InjectRepository(Transmittal)
private transmittalRepo: Repository<Transmittal>,
@InjectRepository(TransmittalItem)
private itemRepo: Repository<TransmittalItem>,
@InjectRepository(CorrespondenceType)
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
private statusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(CorrespondenceRevision)
private revisionRepo: Repository<CorrespondenceRevision>,
private numberingService: DocumentNumberingService,
private dataSource: DataSource,
private uuidResolver: UuidResolverService,
private userService: UserService,
private workflowEngine: WorkflowEngineService
) {}
async create(
createDto: CreateTransmittalDto,
user: User
): Promise<Transmittal & { correspondence: Correspondence }> {
// 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<Transmittal> {
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),
},
};
}
}