260324:1349 Refactor RFA #01
CI / CD Pipeline / build (push) Failing after 1m52s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
admin
2026-03-24 13:49:30 +07:00
parent a3e3206b06
commit 4cd0952482
29 changed files with 1700 additions and 306 deletions
@@ -8,6 +8,7 @@ import {
IsObject,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateRfaDto {
@@ -30,12 +31,14 @@ export class CreateRfaDto {
rfaTypeId!: number;
@ApiProperty({
description: 'ID ของสาขางาน (Discipline) ตาม Req 6B',
description:
'ID ของสาขางาน (Discipline) ตาม Req 6B — Required per spec §3.3.4',
example: 1,
})
@IsInt()
@IsOptional() // Optional ไว้ก่อนเผื่อบางโครงการไม่บังคับ
disciplineId?: number;
@Min(1)
@IsNotEmpty()
disciplineId!: number;
@ApiProperty({
description: 'หัวข้อเอกสาร',
@@ -1,4 +1,14 @@
// File: src/modules/rfa/rfa-workflow.service.ts
//
// NOTE (Phase 4.1 Refactor):
// This service was written as an alternative workflow integration layer using the
// Unified WorkflowEngineService (ADR-001). It is currently NOT called by RfaController.
// Active workflow logic lives in RfaService.submit() / RfaService.processAction().
//
// Reserved for Phase 3: When the Unified Workflow Engine is fully wired to RFA,
// RfaService workflow methods should be migrated here and RfaController updated
// to delegate to RfaWorkflowService.
//
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
+42 -9
View File
@@ -2,9 +2,13 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -20,6 +24,7 @@ import {
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
import { User } from '../user/entities/user.entity';
import { CreateRfaDto } from './dto/create-rfa.dto';
import { UpdateRfaDto } from './dto/update-rfa.dto';
import { SubmitRfaDto } from './dto/submit-rfa.dto';
import { SearchRfaDto } from './dto/search-rfa.dto';
import { RfaService } from './rfa.service';
@@ -31,6 +36,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { ProjectService } from '../project/project.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@ApiTags('RFA (Request for Approval)')
@ApiBearerAuth()
@@ -39,7 +45,8 @@ import { ProjectService } from '../project/project.service';
export class RfaController {
constructor(
private readonly rfaService: RfaService,
private readonly projectService: ProjectService
private readonly projectService: ProjectService,
private readonly uuidResolver: UuidResolverService
) {}
@Post()
@@ -99,17 +106,14 @@ export class RfaController {
@ApiOperation({ summary: 'List all RFAs with pagination' })
@ApiResponse({ status: 200, description: 'List of RFAs' })
@RequirePermission('document.view')
async findAll(@Query() query: SearchRfaDto) {
async findAll(@Query() query: SearchRfaDto, @CurrentUser() user: User) {
// ADR-019: resolve projectId UUID→INT if provided
if (query.projectId) {
const pid = query.projectId;
const num = Number(pid);
if (typeof pid === 'string' && isNaN(num)) {
const project = await this.projectService.findOneByUuid(pid);
query.projectId = project.id;
}
query.projectId = await this.uuidResolver.resolveProjectId(
query.projectId
);
}
return this.rfaService.findAll(query);
return this.rfaService.findAll(query, user);
}
@Get(':uuid')
@@ -123,4 +127,33 @@ export class RfaController {
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.rfaService.findOneByUuid(uuid);
}
@Put(':uuid')
@ApiOperation({ summary: 'Update Draft RFA fields (EC-RFA-002: DFT only)' })
@ApiParam({ name: 'uuid', description: 'RFA UUID' })
@ApiBody({ type: UpdateRfaDto })
@ApiResponse({ status: 200, description: 'RFA updated successfully' })
@RequirePermission('rfa.create')
@Audit('rfa.update', 'rfa')
async update(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateRfaDto,
@CurrentUser() user: User
) {
return this.rfaService.update(uuid, updateDto, user);
}
@Delete(':uuid')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel Draft RFA (sets status to CC)' })
@ApiParam({ name: 'uuid', description: 'RFA UUID' })
@ApiResponse({ status: 200, description: 'RFA cancelled successfully' })
@RequirePermission('rfa.create')
@Audit('rfa.cancel', 'rfa')
async cancel(
@Param('uuid', ParseUuidPipe) uuid: string,
@CurrentUser() user: User
) {
return this.rfaService.cancel(uuid, user);
}
}
+2
View File
@@ -11,6 +11,7 @@ import { CorrespondenceType } from '../correspondence/entities/correspondence-ty
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
import { Organization } from '../organization/entities/organization.entity';
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { Discipline } from '../master/entities/discipline.entity';
@@ -60,6 +61,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
RoutingTemplate,
RoutingTemplateStep,
CorrespondenceRecipient,
Organization,
]),
DocumentNumberingModule,
UserModule,
+123 -5
View File
@@ -23,6 +23,7 @@ import { RoutingTemplateStep } from '../correspondence/entities/routing-template
import { AsBuiltDrawingRevision } from '../drawing/entities/asbuilt-drawing-revision.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { Organization } from '../organization/entities/organization.entity';
import { User } from '../user/entities/user.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { RfaItem } from './entities/rfa-item.entity';
@@ -35,6 +36,7 @@ import { Rfa } from './entities/rfa.entity';
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
import { CreateRfaDto } from './dto/create-rfa.dto';
import { SearchRfaDto } from './dto/search-rfa.dto';
import { UpdateRfaDto } from './dto/update-rfa.dto';
// ------- Local type helpers (no-any ADR-019) -------
/** CorrespondenceRevision with the rfaRevision relation loaded at runtime */
@@ -94,6 +96,8 @@ export class RfaService {
private templateRepo: Repository<RoutingTemplate>,
@InjectRepository(RoutingTemplateStep)
private templateStepRepo: Repository<RoutingTemplateStep>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
private numberingService: DocumentNumberingService,
private userService: UserService,
@@ -232,13 +236,40 @@ export class RfaService {
throw new BadRequestException('User must belong to an organization');
}
// EC-RFA-001: Check for existing active RFA per Shop Drawing Revision
if (shopDrawingRevisionIds.length > 0) {
const conflictingItems = await this.rfaItemRepo
.createQueryBuilder('item')
.innerJoin('item.rfaRevision', 'rfaRev')
.innerJoin('rfaRev.statusCode', 'status')
.where('item.shopDrawingRevisionId IN (:...ids)', {
ids: shopDrawingRevisionIds,
})
.andWhere('status.statusCode NOT IN (:...codes)', {
codes: ['CC', 'OBS'],
})
.getMany();
if (conflictingItems.length > 0) {
throw new BadRequestException(
'[EC-RFA-001] One or more selected Shop Drawing Revisions already have an active RFA. ' +
'A Shop Drawing Revision can only be referenced by one active RFA at a time.'
);
}
}
// Fetch real Organization Code for document numbering
const userOrg = await this.orgRepo.findOne({
where: { id: userOrgId },
select: ['organizationCode'],
});
const orgCode = userOrg?.organizationCode ?? 'ORG';
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: internalProjectId,
@@ -427,7 +458,7 @@ export class RfaService {
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
async findAll(query: SearchRfaDto) {
async findAll(query: SearchRfaDto, _user?: User) {
const {
page = 1,
limit = 20,
@@ -481,14 +512,22 @@ export class RfaService {
);
}
// RBAC: DFT documents are visible only to the originator org (spec §3.3.10)
if (_user?.primaryOrganizationId) {
queryBuilder.andWhere(
'(rfaRev.id IS NULL OR status.statusCode != :dftCode OR corr.originatorId = :userOrgId)',
{ dftCode: 'DFT', userOrgId: _user.primaryOrganizationId }
);
}
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)}`
this.logger.debug(
`RFA findAll: Found ${total} items. Query: ${JSON.stringify(query)}`
);
// Map `revisions` property back to the expected payload for the frontend
@@ -802,4 +841,83 @@ export class RfaService {
await queryRunner.release();
}
}
/**
* Update a Draft RFA's revision fields (subject, body, remarks, description, dueDate).
* EC-RFA-002: Only allowed when current revision is in DFT status.
*/
async update(uuid: string, dto: UpdateRfaDto, _user: User) {
const rfa = await this.findOneByUuidRaw(uuid);
const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
if (!currentCorrRev || !currentCorrRev.rfaRevision)
throw new NotFoundException('Current revision not found');
const currentRfaRev = currentCorrRev.rfaRevision;
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
throw new BadRequestException(
'Only DRAFT documents can be edited. Submit a new revision for non-draft documents.'
);
}
const updatedFields: Partial<CorrespondenceRevision> = {};
if (dto.subject !== undefined) updatedFields.subject = dto.subject;
if (dto.body !== undefined) updatedFields.body = dto.body;
if (dto.remarks !== undefined) updatedFields.remarks = dto.remarks;
if (dto.description !== undefined)
updatedFields.description = dto.description;
if (dto.dueDate !== undefined)
updatedFields.dueDate = new Date(dto.dueDate);
Object.assign(currentCorrRev, updatedFields);
await this.corrRevRepo.save(currentCorrRev);
if (dto.details !== undefined) {
currentRfaRev.details = dto.details;
await this.rfaRevisionRepo.save(currentRfaRev);
}
return this.findOneByUuid(uuid);
}
/**
* Cancel (soft-delete) a Draft RFA by setting its status to CC.
* EC-RFA-002: Only allowed when current revision is in DFT status.
*/
async cancel(uuid: string, user: User) {
const rfa = await this.findOneByUuidRaw(uuid);
const corrRevisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
if (!currentCorrRev || !currentCorrRev.rfaRevision)
throw new NotFoundException('Current revision not found');
const currentRfaRev = currentCorrRev.rfaRevision;
if (currentRfaRev.statusCode.statusCode !== 'DFT') {
throw new BadRequestException(
'Only DRAFT documents can be cancelled. Contact an Org Admin to cancel submitted documents.'
);
}
const statusCC = await this.rfaStatusRepo.findOne({
where: { statusCode: 'CC' },
});
if (!statusCC)
throw new InternalServerErrorException(
'Status CC (Cancelled) not found in Master Data'
);
currentRfaRev.rfaStatusCodeId = statusCC.id;
await this.rfaRevisionRepo.save(currentRfaRev);
this.logger.log(
`RFA ${rfa.correspondence?.correspondenceNumber} cancelled by user ${user.user_id}`
);
return { message: 'RFA cancelled successfully' };
}
}