534 lines
18 KiB
TypeScript
534 lines
18 KiB
TypeScript
// 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/services/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<Rfa>,
|
|
@InjectRepository(RfaRevision)
|
|
private rfaRevisionRepo: Repository<RfaRevision>,
|
|
@InjectRepository(RfaItem)
|
|
private rfaItemRepo: Repository<RfaItem>,
|
|
@InjectRepository(Correspondence)
|
|
private correspondenceRepo: Repository<Correspondence>,
|
|
@InjectRepository(RfaType)
|
|
private rfaTypeRepo: Repository<RfaType>,
|
|
@InjectRepository(RfaStatusCode)
|
|
private rfaStatusRepo: Repository<RfaStatusCode>,
|
|
@InjectRepository(RfaApproveCode)
|
|
private rfaApproveRepo: Repository<RfaApproveCode>,
|
|
@InjectRepository(ShopDrawingRevision)
|
|
private shopDrawingRevRepo: Repository<ShopDrawingRevision>,
|
|
@InjectRepository(CorrespondenceRouting)
|
|
private routingRepo: Repository<CorrespondenceRouting>,
|
|
@InjectRepository(RoutingTemplate)
|
|
private templateRepo: Repository<RoutingTemplate>,
|
|
@InjectRepository(RoutingTemplateStep)
|
|
private templateStepRepo: Repository<RoutingTemplateStep>,
|
|
|
|
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,
|
|
originatorOrganizationId: 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.number,
|
|
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,
|
|
subject: createDto.subject,
|
|
body: createDto.body,
|
|
remarks: createDto.remarks,
|
|
description: createDto.description,
|
|
documentDate: createDto.documentDate
|
|
? new Date(createDto.documentDate)
|
|
: new Date(),
|
|
dueDate: createDto.dueDate ? new Date(createDto.dueDate) : undefined,
|
|
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.number}: ${(error as Error).message}`
|
|
);
|
|
}
|
|
|
|
// Indexing for Search
|
|
this.searchService
|
|
.indexDocument({
|
|
id: savedCorr.id,
|
|
type: 'rfa',
|
|
docNumber: docNumber.number,
|
|
title: createDto.subject,
|
|
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.correspondence', 'corr')
|
|
.leftJoinAndSelect('rfa.revisions', 'rev')
|
|
.leftJoinAndSelect('corr.project', 'project')
|
|
.leftJoinAndSelect('corr.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.subject 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: [
|
|
'correspondence', // ✅ Add relation to master correspondence
|
|
'rfaType',
|
|
'revisions',
|
|
'revisions.statusCode',
|
|
'revisions.approveCode',
|
|
'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: rfa.correspondence.id, // ✅ Use master correspondence id
|
|
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.subject}`,
|
|
message: `RFA ${rfa.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: rfa.correspondence.id, // ✅ Use master correspondence id
|
|
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: rfa.correspondence.id, // ✅ Use master correspondence id
|
|
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();
|
|
}
|
|
}
|
|
}
|