Files
lcbp3/specs/06-tasks/TASK-BE-007-rfa-module.md

14 KiB

Task: RFA Module

Status: Not Started 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

  1. Basic Operations:

    • Create RFA with auto-generated number
    • Add/Update/Delete RFA items
    • Create revision
    • Get RFA with all items and attachments
  2. Approval Workflow:

    • Submit RFA → Start approval workflow
    • Review RFA (Approve/Reject/Revise)
    • Respond to RFA
    • Track approval status
  3. 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);
  });
});


📦 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)