Files
lcbp3/specs/09-history/TASK-BE-009-circulation-transmittal.md
admin c8a0f281ef
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled
251210:1709 Frontend: reeactor organization and run build
2025-12-10 17:09:11 +07:00

14 KiB

Task: Circulation & Transmittal Modules

Status: In Progress Priority: P2 (Medium) Estimated Effort: 5-7 days Dependencies: TASK-BE-001, TASK-BE-002, TASK-BE-003, TASK-BE-006 Owner: Backend Team


📋 Overview

สร้าง Circulation Module (ใบเวียนภายใน) และ Transmittal Module (เอกสารนำส่ง) สำหรับจัดการการส่งเอกสารภายในและภายนอก


🎯 Objectives

  • Circulation Sheet Management
  • Transmittal Management
  • Assignee Tracking
  • Workflow Integration
  • Document Linking

📝 Acceptance Criteria

  1. Circulation:

    • Create circulation sheet
    • Add assignees (multiple users)
    • Link documents (correspondences, RFAs)
    • Track completion status
  2. Transmittal:

    • Create transmittal
    • Add documents
    • Generate transmittal number
    • Print/Export transmittal letter

🛠️ Implementation Steps

1. Circulation Entities

// File: backend/src/modules/circulation/entities/circulation.entity.ts
@Entity('circulations')
export class Circulation {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50, unique: true })
  circulation_number: string;

  @Column({ length: 500 })
  subject: string;

  @Column()
  project_id: number;

  @Column()
  organization_id: number;

  @Column({ default: 'active' })
  status: string;

  @Column({ type: 'date', nullable: true })
  due_date: Date;

  @Column()
  created_by_user_id: number;

  @CreateDateColumn()
  created_at: Date;

  @ManyToOne(() => Project)
  @JoinColumn({ name: 'project_id' })
  project: Project;

  @OneToMany(() => CirculationAssignee, (assignee) => assignee.circulation)
  assignees: CirculationAssignee[];

  @ManyToMany(() => Correspondence)
  @JoinTable({ name: 'circulation_correspondences' })
  correspondences: Correspondence[];
}
// File: backend/src/modules/circulation/entities/circulation-assignee.entity.ts
@Entity('circulation_assignees')
export class CirculationAssignee {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  circulation_id: number;

  @Column()
  user_id: number;

  @Column({ default: 'pending' })
  status: string;

  @Column({ type: 'text', nullable: true })
  remarks: string;

  @Column({ type: 'timestamp', nullable: true })
  completed_at: Date;

  @ManyToOne(() => Circulation, (circ) => circ.assignees)
  @JoinColumn({ name: 'circulation_id' })
  circulation: Circulation;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;
}

2. Transmittal Entities

// File: backend/src/modules/transmittal/entities/transmittal.entity.ts
@Entity('transmittals')
export class Transmittal {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 50, unique: true })
  transmittal_number: string;

  @Column({ length: 500 })
  attention_to: string;

  @Column()
  project_id: number;

  @Column()
  from_organization_id: number;

  @Column()
  to_organization_id: number;

  @Column({ type: 'date' })
  transmittal_date: Date;

  @Column({ type: 'text', nullable: true })
  remarks: string;

  @Column()
  created_by_user_id: number;

  @CreateDateColumn()
  created_at: Date;

  @ManyToOne(() => Project)
  @JoinColumn({ name: 'project_id' })
  project: Project;

  @OneToMany(() => TransmittalItem, (item) => item.transmittal)
  items: TransmittalItem[];
}
// File: backend/src/modules/transmittal/entities/transmittal-item.entity.ts
@Entity('transmittal_items')
export class TransmittalItem {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  transmittal_id: number;

  @Column({ length: 50 })
  document_type: string; // 'correspondence', 'rfa', 'drawing'

  @Column()
  document_id: number;

  @Column({ length: 100 })
  document_number: string;

  @Column({ length: 500, nullable: true })
  document_title: string;

  @Column({ default: 1 })
  number_of_copies: number;

  @ManyToOne(() => Transmittal, (trans) => trans.items)
  @JoinColumn({ name: 'transmittal_id' })
  transmittal: Transmittal;
}

3. Services

// File: backend/src/modules/circulation/circulation.service.ts
@Injectable()
export class CirculationService {
  constructor(
    @InjectRepository(Circulation)
    private circulationRepo: Repository<Circulation>,
    @InjectRepository(CirculationAssignee)
    private assigneeRepo: Repository<CirculationAssignee>,
    private docNumbering: DocumentNumberingService,
    private workflowEngine: WorkflowEngineService,
    private dataSource: DataSource
  ) {}

  async create(
    dto: CreateCirculationDto,
    userId: number
  ): Promise<Circulation> {
    return this.dataSource.transaction(async (manager) => {
      // Generate circulation number
      const circulationNumber = await this.docNumbering.generateNextNumber({
        projectId: dto.project_id,
        organizationId: dto.organization_id,
        typeId: 900, // Circulation type
      });

      // Create circulation
      const circulation = manager.create(Circulation, {
        circulation_number: circulationNumber,
        subject: dto.subject,
        project_id: dto.project_id,
        organization_id: dto.organization_id,
        due_date: dto.due_date,
        status: 'active',
        created_by_user_id: userId,
      });
      await manager.save(circulation);

      // Add assignees
      if (dto.assignee_user_ids?.length > 0) {
        const assignees = dto.assignee_user_ids.map((userId) =>
          manager.create(CirculationAssignee, {
            circulation_id: circulation.id,
            user_id: userId,
            status: 'pending',
          })
        );
        await manager.save(assignees);
      }

      // Link correspondences
      if (dto.correspondence_ids?.length > 0) {
        const correspondences = await manager.findByIds(
          Correspondence,
          dto.correspondence_ids
        );
        circulation.correspondences = correspondences;
        await manager.save(circulation);
      }

      // Create workflow instance
      await this.workflowEngine.createInstance(
        'CIRCULATION_INTERNAL',
        'circulation',
        circulation.id,
        manager
      );

      return circulation;
    });
  }

  async completeAssignment(
    circulationId: number,
    assigneeId: number,
    dto: CompleteAssignmentDto,
    userId: number
  ): Promise<void> {
    const assignee = await this.assigneeRepo.findOne({
      where: { id: assigneeId, circulation_id: circulationId, user_id: userId },
    });

    if (!assignee) {
      throw new NotFoundException('Assignment not found');
    }

    await this.assigneeRepo.update(assigneeId, {
      status: 'completed',
      remarks: dto.remarks,
      completed_at: new Date(),
    });

    // Check if all assignees completed
    const allAssignees = await this.assigneeRepo.find({
      where: { circulation_id: circulationId },
    });

    const allCompleted = allAssignees.every((a) => a.status === 'completed');

    if (allCompleted) {
      await this.circulationRepo.update(circulationId, { status: 'completed' });
      await this.workflowEngine.executeTransition(
        circulationId,
        'COMPLETE',
        userId
      );
    }
  }
}
// File: backend/src/modules/transmittal/transmittal.service.ts
@Injectable()
export class TransmittalService {
  constructor(
    @InjectRepository(Transmittal)
    private transmittalRepo: Repository<Transmittal>,
    @InjectRepository(TransmittalItem)
    private itemRepo: Repository<TransmittalItem>,
    @InjectRepository(Correspondence)
    private correspondenceRepo: Repository<Correspondence>,
    @InjectRepository(Rfa)
    private rfaRepo: Repository<Rfa>,
    private docNumbering: DocumentNumberingService,
    private dataSource: DataSource
  ) {}

  async create(
    dto: CreateTransmittalDto,
    userId: number
  ): Promise<Transmittal> {
    return this.dataSource.transaction(async (manager) => {
      // Generate transmittal number
      const transmittalNumber = await this.docNumbering.generateNextNumber({
        projectId: dto.project_id,
        organizationId: dto.from_organization_id,
        typeId: 901, // Transmittal type
      });

      // Create transmittal
      const transmittal = manager.create(Transmittal, {
        transmittal_number: transmittalNumber,
        attention_to: dto.attention_to,
        project_id: dto.project_id,
        from_organization_id: dto.from_organization_id,
        to_organization_id: dto.to_organization_id,
        transmittal_date: dto.transmittal_date || new Date(),
        remarks: dto.remarks,
        created_by_user_id: userId,
      });
      await manager.save(transmittal);

      // Add items
      if (dto.items?.length > 0) {
        for (const itemDto of dto.items) {
          // Fetch document details
          const docDetails = await this.getDocumentDetails(
            itemDto.document_type,
            itemDto.document_id,
            manager
          );

          const item = manager.create(TransmittalItem, {
            transmittal_id: transmittal.id,
            document_type: itemDto.document_type,
            document_id: itemDto.document_id,
            document_number: docDetails.number,
            document_title: docDetails.title,
            number_of_copies: itemDto.number_of_copies || 1,
          });

          await manager.save(item);
        }
      }

      return transmittal;
    });
  }

  private async getDocumentDetails(
    type: string,
    id: number,
    manager: EntityManager
  ): Promise<{ number: string; title: string }> {
    switch (type) {
      case 'correspondence':
        const corr = await manager.findOne(Correspondence, { where: { id } });
        return { number: corr.correspondence_number, title: corr.title };

      case 'rfa':
        const rfa = await manager.findOne(Rfa, { where: { id } });
        return { number: rfa.rfa_number, title: rfa.subject };

      default:
        throw new BadRequestException(`Unknown document type: ${type}`);
    }
  }

  async findOne(id: number): Promise<Transmittal> {
    const transmittal = await this.transmittalRepo.findOne({
      where: { id },
      relations: ['items', 'project'],
    });

    if (!transmittal) {
      throw new NotFoundException(`Transmittal #${id} not found`);
    }

    return transmittal;
  }

  async generatePDF(id: number): Promise<Buffer> {
    const transmittal = await this.findOne(id);

    // Generate PDF using template
    // Implementation with library like pdfmake or puppeteer

    return Buffer.from('PDF content');
  }
}

4. Controllers

// File: backend/src/modules/circulation/circulation.controller.ts
@Controller('circulations')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class CirculationController {
  constructor(private service: CirculationService) {}

  @Post()
  @RequirePermission('circulation.create')
  async create(@Body() dto: CreateCirculationDto, @CurrentUser() user: User) {
    return this.service.create(dto, user.user_id);
  }

  @Post(':circulationId/assignees/:assigneeId/complete')
  @RequirePermission('circulation.complete')
  async completeAssignment(
    @Param('circulationId', ParseIntPipe) circulationId: number,
    @Param('assigneeId', ParseIntPipe) assigneeId: number,
    @Body() dto: CompleteAssignmentDto,
    @CurrentUser() user: User
  ) {
    return this.service.completeAssignment(
      circulationId,
      assigneeId,
      dto,
      user.user_id
    );
  }
}
// File: backend/src/modules/transmittal/transmittal.controller.ts
@Controller('transmittals')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class TransmittalController {
  constructor(private service: TransmittalService) {}

  @Post()
  @RequirePermission('transmittal.create')
  @UseInterceptors(IdempotencyInterceptor)
  async create(@Body() dto: CreateTransmittalDto, @CurrentUser() user: User) {
    return this.service.create(dto, user.user_id);
  }

  @Get(':id')
  @RequirePermission('transmittal.view')
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.service.findOne(id);
  }

  @Get(':id/pdf')
  @RequirePermission('transmittal.view')
  async downloadPDF(
    @Param('id', ParseIntPipe) id: number,
    @Res() res: Response
  ) {
    const pdf = await this.service.generatePDF(id);

    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader(
      'Content-Disposition',
      `attachment; filename=transmittal-${id}.pdf`
    );
    res.send(pdf);
  }
}

Testing & Verification

1. Unit Tests

describe('CirculationService', () => {
  it('should create circulation with assignees', async () => {
    const dto = {
      subject: 'Review Documents',
      project_id: 1,
      organization_id: 3,
      assignee_user_ids: [1, 2, 3],
      correspondence_ids: [10, 11],
    };

    const result = await service.create(dto, 1);

    expect(result.assignees).toHaveLength(3);
    expect(result.correspondences).toHaveLength(2);
  });
});

describe('TransmittalService', () => {
  it('should create transmittal with document items', async () => {
    const dto = {
      attention_to: 'Project Manager',
      project_id: 1,
      from_organization_id: 3,
      to_organization_id: 1,
      items: [
        { document_type: 'correspondence', document_id: 10 },
        { document_type: 'rfa', document_id: 5 },
      ],
    };

    const result = await service.create(dto, 1);

    expect(result.items).toHaveLength(2);
  });
});


📦 Deliverables

  • Circulation & CirculationAssignee Entities
  • Transmittal & TransmittalItem Entities
  • Services (Both modules)
  • Controllers
  • DTOs
  • PDF Generation (Transmittal)
  • Unit Tests (80% coverage)
  • API Documentation

🚨 Risks & Mitigation

Risk Impact Mitigation
PDF generation complexity Medium Use proven library (pdfmake)
Multi-assignee tracking Medium Clear status management
Document linking Low Foreign key validation

📌 Notes

  • Circulation tracks multiple assignees
  • All assignees must complete before circulation closes
  • Transmittal can include multiple document types
  • PDF template for transmittal letter
  • Auto-numbering for both modules