579 lines
14 KiB
Markdown
579 lines
14 KiB
Markdown
# Task: Circulation & Transmittal Modules
|
|
|
|
**Status:** Not Started
|
|
**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
|
|
|
|
```typescript
|
|
// 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[];
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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[];
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 Related Documents
|
|
|
|
- [Functional Requirements - Circulation](../01-requirements/03.8-circulation-sheet.md)
|
|
- [Functional Requirements - Transmittal](../01-requirements/03.7-transmittals.md)
|
|
|
|
---
|
|
|
|
## 📦 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
|