14 KiB
14 KiB
Task: RFA Module
Status: In Progress Priority: P1 (High - Core Business Module) Estimated Effort: 8-12 days Dependencies: TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-004, TASK-BE-006 Owner: Backend Team
📋 Overview
สร้าง RFA (Request for Approval) Module สำหรับจัดการเอกสารขออนุมัติด้วย Master-Revision Pattern พร้อม Approval Workflow
🎯 Objectives
- ✅ CRUD Operations (RFAs + Revisions + Items)
- ✅ Master-Revision Pattern
- ✅ RFA Items Management
- ✅ Approval Workflow Integration
- ✅ Response/Approve Actions
- ✅ Status Tracking
📝 Acceptance Criteria
-
Basic Operations:
- ✅ Create RFA with auto-generated number
- ✅ Add/Update/Delete RFA items
- ✅ Create revision
- ✅ Get RFA with all items and attachments
-
Approval Workflow:
- ✅ Submit RFA → Start approval workflow
- ✅ Review RFA (Approve/Reject/Revise)
- ✅ Respond to RFA
- ✅ Track approval status
-
RFA Items:
- ✅ Add multiple items to RFA
- ✅ Link items to drawings (optional)
- ✅ Item-level approval tracking
🛠️ Implementation Steps
1. Entities
// File: backend/src/modules/rfa/entities/rfa.entity.ts
@Entity('rfas')
export class Rfa extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 50, unique: true })
rfa_number: string;
@Column({ length: 500 })
subject: string;
@Column()
project_id: number;
@Column()
contractor_organization_id: number;
@Column()
consultant_organization_id: number;
@Column()
rfa_type_id: number;
@Column({ nullable: true })
discipline_id: number;
@Column({ default: 'draft' })
status: string;
@Column({ nullable: true })
approved_code_id: number; // Final approval result
@Column()
created_by_user_id: number;
@DeleteDateColumn()
deleted_at: Date;
// Relationships
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => RfaRevision, (rev) => rev.rfa)
revisions: RfaRevision[];
@OneToMany(() => RfaItem, (item) => item.rfa)
items: RfaItem[];
@ManyToOne(() => RfaApproveCode)
@JoinColumn({ name: 'approved_code_id' })
approvedCode: RfaApproveCode;
}
// File: backend/src/modules/rfa/entities/rfa-revision.entity.ts
@Entity('rfa_revisions')
export class RfaRevision {
@PrimaryGeneratedColumn()
id: number;
@Column()
rfa_id: number;
@Column({ default: 1 })
revision_number: number;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'json', nullable: true })
details: any;
@Column({ type: 'date', nullable: true })
required_date: Date;
@Column()
created_by_user_id: number;
@CreateDateColumn()
created_at: Date;
@ManyToOne(() => Rfa, (rfa) => rfa.revisions)
@JoinColumn({ name: 'rfa_id' })
rfa: Rfa;
@ManyToMany(() => Attachment)
@JoinTable({
name: 'rfa_attachments',
joinColumn: { name: 'rfa_revision_id' },
inverseJoinColumn: { name: 'attachment_id' },
})
attachments: Attachment[];
}
// File: backend/src/modules/rfa/entities/rfa-item.entity.ts
@Entity('rfa_items')
export class RfaItem {
@PrimaryGeneratedColumn()
id: number;
@Column()
rfa_id: number;
@Column({ length: 500 })
item_description: string;
@Column({ nullable: true })
drawing_id: number;
@Column({ nullable: true })
location: string;
@Column({ nullable: true })
quantity: number;
@Column({ length: 50, nullable: true })
unit: string;
@Column({ type: 'text', nullable: true })
remarks: string;
@ManyToOne(() => Rfa, (rfa) => rfa.items)
@JoinColumn({ name: 'rfa_id' })
rfa: Rfa;
@ManyToOne(() => ShopDrawing)
@JoinColumn({ name: 'drawing_id' })
drawing: ShopDrawing;
}
2. Service
// File: backend/src/modules/rfa/rfa.service.ts
@Injectable()
export class RfaService {
constructor(
@InjectRepository(Rfa)
private rfaRepo: Repository<Rfa>,
@InjectRepository(RfaRevision)
private revisionRepo: Repository<RfaRevision>,
@InjectRepository(RfaItem)
private itemRepo: Repository<RfaItem>,
private fileStorage: FileStorageService,
private docNumbering: DocumentNumberingService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource
) {}
async create(dto: CreateRfaDto, userId: number): Promise<Rfa> {
return this.dataSource.transaction(async (manager) => {
// 1. Generate RFA number
const rfaNumber = await this.docNumbering.generateNextNumber({
projectId: dto.project_id,
organizationId: dto.contractor_organization_id,
typeId: dto.rfa_type_id,
disciplineId: dto.discipline_id,
});
// 2. Create RFA master
const rfa = manager.create(Rfa, {
rfa_number: rfaNumber,
subject: dto.subject,
project_id: dto.project_id,
contractor_organization_id: dto.contractor_organization_id,
consultant_organization_id: dto.consultant_organization_id,
rfa_type_id: dto.rfa_type_id,
discipline_id: dto.discipline_id,
status: 'draft',
created_by_user_id: userId,
});
await manager.save(rfa);
// 3. Create initial revision
const revision = manager.create(RfaRevision, {
rfa_id: rfa.id,
revision_number: 1,
description: dto.description,
details: dto.details,
required_date: dto.required_date,
created_by_user_id: userId,
});
await manager.save(revision);
// 4. Create RFA items
if (dto.items?.length > 0) {
const items = dto.items.map((item) =>
manager.create(RfaItem, {
rfa_id: rfa.id,
...item,
})
);
await manager.save(items);
}
// 5. Commit temp files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
rfa.id,
'rfa',
manager
);
revision.attachments = attachments;
await manager.save(revision);
}
// 6. Create workflow instance
await this.workflowEngine.createInstance(
'RFA_APPROVAL',
'rfa',
rfa.id,
manager
);
return rfa;
});
}
async submitForApproval(id: number, userId: number): Promise<void> {
const rfa = await this.findOne(id);
if (rfa.status !== 'draft') {
throw new BadRequestException('Can only submit draft RFAs');
}
// Validate items exist
if (!rfa.items || rfa.items.length === 0) {
throw new BadRequestException('RFA must have at least one item');
}
// Execute workflow transition
await this.workflowEngine.executeTransition(rfa.id, 'SUBMIT', userId);
// Update status
await this.rfaRepo.update(id, { status: 'submitted' });
}
async reviewRfa(
id: number,
action: 'approve' | 'reject' | 'revise',
dto: ReviewRfaDto,
userId: number
): Promise<void> {
const rfa = await this.findOne(id);
if (rfa.status !== 'submitted' && rfa.status !== 'under_review') {
throw new BadRequestException('Invalid RFA status for review');
}
// Execute workflow transition
const workflowAction = action.toUpperCase();
await this.workflowEngine.executeTransition(rfa.id, workflowAction, userId);
// Update RFA status and approval code
const updates: any = {
status:
action === 'approve'
? 'approved'
: action === 'reject'
? 'rejected'
: 'revising',
};
if (action === 'approve' && dto.approve_code_id) {
updates.approved_code_id = dto.approve_code_id;
}
await this.rfaRepo.update(id, updates);
}
async respondToRfa(
id: number,
dto: RespondRfaDto,
userId: number
): Promise<void> {
return this.dataSource.transaction(async (manager) => {
const rfa = await this.findOne(id);
if (rfa.status !== 'approved' && rfa.status !== 'rejected') {
throw new BadRequestException('RFA must be reviewed first');
}
// Create response revision
const latestRevision = await manager.findOne(RfaRevision, {
where: { rfa_id: id },
order: { revision_number: 'DESC' },
});
const responseRevision = manager.create(RfaRevision, {
rfa_id: id,
revision_number: (latestRevision?.revision_number || 0) + 1,
description: dto.response_description,
details: dto.response_details,
created_by_user_id: userId,
});
await manager.save(responseRevision);
// Commit response files
if (dto.temp_file_ids?.length > 0) {
const attachments = await this.fileStorage.commitFiles(
dto.temp_file_ids,
id,
'rfa',
manager
);
responseRevision.attachments = attachments;
await manager.save(responseRevision);
}
// Update status
await manager.update(Rfa, id, { status: 'responded' });
// Execute workflow
await this.workflowEngine.executeTransition(id, 'RESPOND', userId);
});
}
async findAll(query: SearchRfaDto): Promise<PaginatedResult<Rfa>> {
const queryBuilder = this.rfaRepo
.createQueryBuilder('rfa')
.leftJoinAndSelect('rfa.project', 'project')
.leftJoinAndSelect('rfa.items', 'items')
.leftJoinAndSelect('rfa.approvedCode', 'approvedCode')
.where('rfa.deleted_at IS NULL');
// Apply filters
if (query.project_id) {
queryBuilder.andWhere('rfa.project_id = :projectId', {
projectId: query.project_id,
});
}
if (query.status) {
queryBuilder.andWhere('rfa.status = :status', { status: query.status });
}
if (query.search) {
queryBuilder.andWhere(
'(rfa.subject LIKE :search OR rfa.rfa_number LIKE :search)',
{ search: `%${query.search}%` }
);
}
// Pagination
const page = query.page || 1;
const limit = query.limit || 20;
const skip = (page - 1) * limit;
const [items, total] = await queryBuilder
.orderBy('rfa.created_at', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
async findOne(id: number): Promise<Rfa> {
const rfa = await this.rfaRepo.findOne({
where: { id, deleted_at: IsNull() },
relations: [
'revisions',
'revisions.attachments',
'items',
'items.drawing',
'project',
'approvedCode',
],
order: { revisions: { revision_number: 'DESC' } },
});
if (!rfa) {
throw new NotFoundException(`RFA #${id} not found`);
}
return rfa;
}
}
3. Controller
// File: backend/src/modules/rfa/rfa.controller.ts
@Controller('rfas')
@UseGuards(JwtAuthGuard, PermissionGuard)
@ApiTags('RFAs')
export class RfaController {
constructor(private service: RfaService) {}
@Post()
@RequirePermission('rfa.create')
@UseInterceptors(IdempotencyInterceptor)
async create(
@Body() dto: CreateRfaDto,
@CurrentUser() user: User
): Promise<Rfa> {
return this.service.create(dto, user.user_id);
}
@Post(':id/submit')
@RequirePermission('rfa.submit')
async submit(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: User
) {
return this.service.submitForApproval(id, user.user_id);
}
@Post(':id/review')
@RequirePermission('rfa.review')
async review(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ReviewRfaDto,
@CurrentUser() user: User
) {
return this.service.reviewRfa(id, dto.action, dto, user.user_id);
}
@Post(':id/respond')
@RequirePermission('rfa.respond')
async respond(
@Param('id', ParseIntPipe) id: number,
@Body() dto: RespondRfaDto,
@CurrentUser() user: User
) {
return this.service.respondToRfa(id, dto, user.user_id);
}
@Get()
@RequirePermission('rfa.view')
async findAll(@Query() query: SearchRfaDto) {
return this.service.findAll(query);
}
@Get(':id')
@RequirePermission('rfa.view')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(id);
}
}
✅ Testing & Verification
1. Unit Tests
describe('RfaService', () => {
it('should create RFA with items', async () => {
const dto = {
subject: 'Test RFA',
project_id: 1,
contractor_organization_id: 3,
consultant_organization_id: 1,
rfa_type_id: 1,
items: [
{ item_description: 'Item 1', quantity: 10, unit: 'pcs' },
{ item_description: 'Item 2', quantity: 5, unit: 'm' },
],
};
const result = await service.create(dto, 1);
expect(result.rfa_number).toMatch(/^TEAM-RFA-\d{4}-\d{4}$/);
expect(result.items).toHaveLength(2);
});
it('should execute approval workflow', async () => {
await service.submitForApproval(rfa.id, userId);
await service.reviewRfa(
rfa.id,
'approve',
{ approve_code_id: 1 },
reviewerId
);
const updated = await service.findOne(rfa.id);
expect(updated.status).toBe('approved');
expect(updated.approved_code_id).toBe(1);
});
});
📚 Related Documents
📦 Deliverables
- Rfa, RfaRevision, RfaItem Entities
- RfaService (CRUD + Approval Workflow)
- RfaController
- DTOs (Create, Review, Respond, Search)
- Unit Tests (85% coverage)
- Integration Tests
- API Documentation
🚨 Risks & Mitigation
| Risk | Impact | Mitigation |
|---|---|---|
| Complex approval workflow | High | Clear state machine definition |
| Item management complexity | Medium | Transaction-safe CRUD |
| Response/revision tracking | Medium | Clear revision numbering |
📌 Notes
- RFA Items required before submit
- Approval codes from master data table
- Support multi-level approval workflow
- Response creates new revision
- Link items to drawings (optional)