feat(rfa): complete RFA Approval Refactor - all 9 phases (T001-T080)
Phase 1-2: Setup, SQL schema, enums, queue constants, base entities
Phase 3 (US1): ReviewTeam, ReviewTeamMember, ReviewTask, TaskCreationService
Phase 4 (US2): ResponseCode, ResponseCodeRule, ImplicationsService, NotificationTriggerService
Phase 5 (US3): Delegation entity, CircularDetectionService, DelegationService/Controller/Module
Phase 6 (US4): ReminderRule, SchedulerService, EscalationService, ReminderProcessor, ReminderModule
Phase 7 (US5): DistributionMatrix, DistributionRecipient, ApprovalListenerService (Strangler),
TransmittalCreatorService, DistributionProcessor, DistributionModule
Phase 8 (US6): MatrixManagementService, InheritanceService (global→project override)
Phase 9 (Polish): AggregateStatusService, ConsensusService, VetoOverrideService,
ParallelGatewayHandler, review-validators, optimistic locking in completeReview,
test stubs (unit/integration/e2e), jest.config.js updated for tests/ directory
Frontend: ReviewTaskInbox, ParallelProgress, VetoOverrideDialog, DelegationForm,
DelegatedBadge, MatrixEditor, ProjectOverrideManager, DistributionStatus,
ReminderHistory, ResponseCodeSelector, CodeImplications, CompleteReviewForm,
ReviewTeamForm, ReviewTeamSelector, TeamMemberManager
Closes #1
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// File: src/modules/review-team/dto/shared/review-team.dto.ts
|
||||
// Shared DTOs สำหรับ Review Team และ Review Task APIs
|
||||
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsPositive,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ReviewTaskStatus,
|
||||
ReviewTeamMemberRole,
|
||||
DelegationScope,
|
||||
} from '../../../common/enums/review.enums';
|
||||
|
||||
// ─── Review Team DTOs ──────────────────────────────────────────────────────
|
||||
|
||||
export class CreateReviewTeamDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
projectPublicId!: string; // ADR-019: รับ publicId เสมอ
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
defaultForRfaTypes?: string[]; // ['SDW', 'DDW', 'ADW']
|
||||
}
|
||||
|
||||
export class UpdateReviewTeamDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
defaultForRfaTypes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class AddTeamMemberDto {
|
||||
@IsUUID()
|
||||
userPublicId!: string; // ADR-019
|
||||
|
||||
@IsUUID()
|
||||
disciplinePublicId!: string; // ADR-019
|
||||
|
||||
@IsEnum(ReviewTeamMemberRole)
|
||||
role!: ReviewTeamMemberRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
priorityOrder?: number;
|
||||
}
|
||||
|
||||
export class SearchReviewTeamDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectPublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ─── Review Task DTOs ──────────────────────────────────────────────────────
|
||||
|
||||
export class CompleteReviewTaskDto {
|
||||
@IsUUID()
|
||||
responseCodePublicId!: string; // ADR-019: รับ publicId
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
comments?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
attachmentPublicIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateReviewTaskStatusDto {
|
||||
@IsEnum(ReviewTaskStatus)
|
||||
status!: ReviewTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class SearchReviewTaskDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
rfaRevisionPublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ReviewTaskStatus)
|
||||
status?: ReviewTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
assignedToUserPublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDateTo?: string;
|
||||
}
|
||||
|
||||
// ─── Delegation DTOs ───────────────────────────────────────────────────────
|
||||
|
||||
export class CreateDelegationDto {
|
||||
@IsUUID()
|
||||
delegateePublicId!: string; // ADR-019
|
||||
|
||||
@IsDateString()
|
||||
startDate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsEnum(DelegationScope)
|
||||
scope!: DelegationScope;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
documentTypes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ─── Veto Override DTO ─────────────────────────────────────────────────────
|
||||
|
||||
export class VetoOverrideDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(1000)
|
||||
justification!: string; // PM ต้องระบุเหตุผลที่ชัดเจน (min 10 chars)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// File: src/modules/review-team/entities/review-task.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
VersionColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { ReviewTeam } from './review-team.entity';
|
||||
import { ResponseCode } from '../../response-code/entities/response-code.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Discipline } from '../../master/entities/discipline.entity';
|
||||
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('review_tasks')
|
||||
export class ReviewTask extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'rfa_revision_id' })
|
||||
@Exclude()
|
||||
rfaRevisionId!: number;
|
||||
|
||||
@Column({ name: 'team_id' })
|
||||
@Exclude()
|
||||
teamId!: number;
|
||||
|
||||
@Column({ name: 'discipline_id' })
|
||||
@Exclude()
|
||||
disciplineId!: number;
|
||||
|
||||
@Column({ name: 'assigned_to_user_id', nullable: true })
|
||||
@Exclude()
|
||||
assignedToUserId?: number; // NULL = auto-assign ตาม discipline
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ReviewTaskStatus,
|
||||
default: ReviewTaskStatus.PENDING,
|
||||
})
|
||||
status!: ReviewTaskStatus;
|
||||
|
||||
@Column({ name: 'due_date', type: 'date', nullable: true })
|
||||
dueDate?: Date;
|
||||
|
||||
@Column({ name: 'response_code_id', nullable: true })
|
||||
@Exclude()
|
||||
responseCodeId?: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
comments?: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
attachments?: string[]; // Array ของ attachment publicIds
|
||||
|
||||
@Column({ name: 'delegated_from_user_id', nullable: true })
|
||||
@Exclude()
|
||||
delegatedFromUserId?: number; // ติดตาม delegation chain
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamp', nullable: true })
|
||||
completedAt?: Date;
|
||||
|
||||
@VersionColumn()
|
||||
version!: number; // Optimistic locking (ADR-002)
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReviewTeam)
|
||||
@JoinColumn({ name: 'team_id' })
|
||||
team?: ReviewTeam;
|
||||
|
||||
@ManyToOne(() => Discipline)
|
||||
@JoinColumn({ name: 'discipline_id' })
|
||||
discipline?: Discipline;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'assigned_to_user_id' })
|
||||
assignedToUser?: User;
|
||||
|
||||
@ManyToOne(() => ResponseCode)
|
||||
@JoinColumn({ name: 'response_code_id' })
|
||||
responseCode?: ResponseCode;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'delegated_from_user_id' })
|
||||
delegatedFromUser?: User;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// File: src/modules/review-team/entities/review-team-member.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Discipline } from '../../master/entities/discipline.entity';
|
||||
import { ReviewTeam } from './review-team.entity';
|
||||
import { ReviewTeamMemberRole } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('review_team_members')
|
||||
export class ReviewTeamMember extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'team_id' })
|
||||
@Exclude()
|
||||
teamId!: number;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
@Exclude()
|
||||
userId!: number;
|
||||
|
||||
@Column({ name: 'discipline_id' })
|
||||
@Exclude()
|
||||
disciplineId!: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ReviewTeamMemberRole,
|
||||
default: ReviewTeamMemberRole.REVIEWER,
|
||||
})
|
||||
role!: ReviewTeamMemberRole;
|
||||
|
||||
@Column({ name: 'priority_order', default: 0 })
|
||||
priorityOrder!: number; // สำหรับ fallback sequential assignment
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'team_id' })
|
||||
team!: ReviewTeam;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user?: User;
|
||||
|
||||
@ManyToOne(() => Discipline)
|
||||
@JoinColumn({ name: 'discipline_id' })
|
||||
discipline?: Discipline;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// File: src/modules/review-team/entities/review-team.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
import { ReviewTeamMember } from './review-team-member.entity';
|
||||
|
||||
@Entity('review_teams')
|
||||
export class ReviewTeam extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'project_id' })
|
||||
@Exclude()
|
||||
projectId!: number;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name!: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ name: 'default_for_rfa_types', type: 'simple-array', nullable: true })
|
||||
defaultForRfaTypes?: string[]; // Auto-assign ให้ RFA type เช่น ['SDW','DDW']
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project?: Project;
|
||||
|
||||
@OneToMany(() => ReviewTeamMember, (member: ReviewTeamMember) => member.team, { cascade: true })
|
||||
members?: ReviewTeamMember[];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// File: src/modules/review-team/review-task.service.ts
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTask } from './entities/review-task.entity';
|
||||
import { ResponseCode } from '../response-code/entities/response-code.entity';
|
||||
import {
|
||||
CompleteReviewTaskDto,
|
||||
SearchReviewTaskDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
import { ReviewTaskStatus } from '../common/enums/review.enums';
|
||||
import { validateTaskCompletionRequirements } from '../../common/validators/review-validators';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewTaskService {
|
||||
private readonly logger = new Logger(ReviewTaskService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึง Tasks ทั้งหมดของ RFA Revision (internal use)
|
||||
*/
|
||||
async findByRevisionId(rfaRevisionId: number): Promise<ReviewTask[]> {
|
||||
return this.reviewTaskRepo.find({ where: { rfaRevisionId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหา Review Tasks ตาม filter (FR-004)
|
||||
*/
|
||||
async findAll(dto: SearchReviewTaskDto): Promise<ReviewTask[]> {
|
||||
const qb = this.reviewTaskRepo
|
||||
.createQueryBuilder('task')
|
||||
.leftJoinAndSelect('task.discipline', 'discipline')
|
||||
.leftJoinAndSelect('task.assignedToUser', 'user')
|
||||
.leftJoinAndSelect('task.responseCode', 'responseCode')
|
||||
.leftJoinAndSelect('task.team', 'team');
|
||||
|
||||
if (dto.rfaRevisionPublicId) {
|
||||
qb.innerJoin('rfa_revisions', 'rev', 'rev.id = task.rfa_revision_id')
|
||||
.where('rev.uuid = :uuid', { uuid: dto.rfaRevisionPublicId });
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
qb.andWhere('task.status = :status', { status: dto.status });
|
||||
}
|
||||
|
||||
if (dto.assignedToUserPublicId) {
|
||||
qb.andWhere('user.uuid = :userUuid', { userUuid: dto.assignedToUserPublicId });
|
||||
}
|
||||
|
||||
if (dto.dueDateFrom) {
|
||||
qb.andWhere('task.due_date >= :from', { from: dto.dueDateFrom });
|
||||
}
|
||||
|
||||
if (dto.dueDateTo) {
|
||||
qb.andWhere('task.due_date <= :to', { to: dto.dueDateTo });
|
||||
}
|
||||
|
||||
return qb.orderBy('task.created_at', 'ASC').getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Review Task ตาม publicId (ADR-019)
|
||||
*/
|
||||
async findByPublicId(publicId: string): Promise<ReviewTask> {
|
||||
const task = await this.reviewTaskRepo.findOne({
|
||||
where: { publicId },
|
||||
relations: ['discipline', 'assignedToUser', 'responseCode', 'team'],
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Review Task not found: ${publicId}`);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
|
||||
*/
|
||||
async getAggregateStatus(rfaRevisionId: number): Promise<{
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
summary: string;
|
||||
}> {
|
||||
const tasks = await this.reviewTaskRepo.find({ where: { rfaRevisionId } });
|
||||
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(
|
||||
(t: ReviewTask) =>
|
||||
t.status === ReviewTaskStatus.COMPLETED || t.status === ReviewTaskStatus.CANCELLED,
|
||||
).length;
|
||||
const pending = total - completed;
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
pending,
|
||||
summary: `${completed} of ${total} Disciplines Reviewed`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* เริ่มตรวจสอบ Review Task (เปลี่ยน status จาก PENDING → IN_PROGRESS)
|
||||
*/
|
||||
async startReview(publicId: string): Promise<ReviewTask> {
|
||||
const task = await this.findByPublicId(publicId);
|
||||
|
||||
if (task.status !== ReviewTaskStatus.PENDING) {
|
||||
throw new BadRequestException(
|
||||
`Cannot start review: task is already ${task.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
task.status = ReviewTaskStatus.IN_PROGRESS;
|
||||
return this.reviewTaskRepo.save(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกผลการตรวจสอบ (FR-009, T069)
|
||||
* ใช้ Optimistic Locking (@VersionColumn) ป้องกัน race condition (ADR-002)
|
||||
*/
|
||||
async completeReview(publicId: string, dto: CompleteReviewTaskDto): Promise<ReviewTask> {
|
||||
const task = await this.findByPublicId(publicId);
|
||||
|
||||
if (
|
||||
task.status === ReviewTaskStatus.COMPLETED ||
|
||||
task.status === ReviewTaskStatus.CANCELLED
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Cannot complete review: task is already ${task.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ตรวจสอบ Response Code (ADR-019)
|
||||
const responseCode = await this.responseCodeRepo.findOne({
|
||||
where: { publicId: dto.responseCodePublicId },
|
||||
});
|
||||
|
||||
if (!responseCode) {
|
||||
throw new NotFoundException(
|
||||
`Response Code not found: ${dto.responseCodePublicId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate completion requirements (T073)
|
||||
validateTaskCompletionRequirements(
|
||||
ReviewTaskStatus.COMPLETED,
|
||||
responseCode.id,
|
||||
false, // requiresComments checked at controller level via ResponseCodeRule
|
||||
dto.comments,
|
||||
);
|
||||
|
||||
task.status = ReviewTaskStatus.COMPLETED;
|
||||
task.responseCodeId = responseCode.id;
|
||||
task.comments = dto.comments;
|
||||
task.attachments = dto.attachmentPublicIds;
|
||||
task.completedAt = new Date();
|
||||
|
||||
try {
|
||||
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
|
||||
return await this.reviewTaskRepo.save(task);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (errorMessage.includes('OptimisticLock') || errorMessage.includes('version')) {
|
||||
throw new ConflictException(
|
||||
'Review task was modified concurrently. Please refresh and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// File: src/modules/review-team/review-team.controller.ts
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ReviewTeamService } from './review-team.service';
|
||||
import {
|
||||
CreateReviewTeamDto,
|
||||
UpdateReviewTeamDto,
|
||||
AddTeamMemberDto,
|
||||
SearchReviewTeamDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
|
||||
@Controller('review-teams')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewTeamController {
|
||||
constructor(private readonly reviewTeamService: ReviewTeamService) {}
|
||||
|
||||
/**
|
||||
* GET /review-teams
|
||||
* ดึงรายการ Review Teams ตาม project
|
||||
*/
|
||||
@Get()
|
||||
findAll(@Query() dto: SearchReviewTeamDto) {
|
||||
return this.reviewTeamService.findAll(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /review-teams/:publicId
|
||||
* ดึง Review Team เดียว (ADR-019)
|
||||
*/
|
||||
@Get(':publicId')
|
||||
findOne(@Param('publicId') publicId: string) {
|
||||
return this.reviewTeamService.findByPublicId(publicId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /review-teams
|
||||
* สร้าง Review Team ใหม่
|
||||
*/
|
||||
@Post()
|
||||
create(@Body() dto: CreateReviewTeamDto) {
|
||||
return this.reviewTeamService.create(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /review-teams/:publicId
|
||||
* อัปเดต Review Team
|
||||
*/
|
||||
@Patch(':publicId')
|
||||
update(@Param('publicId') publicId: string, @Body() dto: UpdateReviewTeamDto) {
|
||||
return this.reviewTeamService.update(publicId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /review-teams/:publicId/members
|
||||
* เพิ่มสมาชิก
|
||||
*/
|
||||
@Post(':publicId/members')
|
||||
addMember(@Param('publicId') teamPublicId: string, @Body() dto: AddTeamMemberDto) {
|
||||
return this.reviewTeamService.addMember(teamPublicId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /review-teams/:publicId/members/:memberPublicId
|
||||
* ลบสมาชิก
|
||||
*/
|
||||
@Delete(':publicId/members/:memberPublicId')
|
||||
removeMember(
|
||||
@Param('publicId') teamPublicId: string,
|
||||
@Param('memberPublicId') memberPublicId: string,
|
||||
) {
|
||||
return this.reviewTeamService.removeMember(teamPublicId, memberPublicId);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /review-teams/:publicId
|
||||
* Deactivate Review Team (soft delete)
|
||||
*/
|
||||
@Delete(':publicId')
|
||||
deactivate(@Param('publicId') publicId: string) {
|
||||
return this.reviewTeamService.deactivate(publicId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// File: src/modules/review-team/review-team.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
|
||||
// Entities
|
||||
import { ReviewTeam } from './entities/review-team.entity';
|
||||
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
||||
import { ReviewTask } from './entities/review-task.entity';
|
||||
|
||||
// External entities needed for resolution
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
|
||||
// Services
|
||||
import { ReviewTeamService } from './review-team.service';
|
||||
import { ReviewTaskService } from './review-task.service';
|
||||
import { TaskCreationService } from './services/task-creation.service';
|
||||
import { AggregateStatusService } from './services/aggregate-status.service';
|
||||
import { ConsensusService } from './services/consensus.service';
|
||||
import { VetoOverrideService } from './services/veto-override.service';
|
||||
|
||||
// Controllers
|
||||
import { ReviewTeamController } from './review-team.controller';
|
||||
|
||||
// Modules
|
||||
import { ResponseCodeModule } from '../response-code/response-code.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { DistributionModule } from '../distribution/distribution.module';
|
||||
|
||||
// Queue constants
|
||||
import { QUEUE_REMINDERS, QUEUE_VETO_NOTIFICATIONS } from '../common/constants/queue.constants';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ReviewTeam, ReviewTeamMember, ReviewTask, User, Discipline]),
|
||||
BullModule.registerQueue(
|
||||
{ name: QUEUE_REMINDERS },
|
||||
{ name: QUEUE_VETO_NOTIFICATIONS },
|
||||
),
|
||||
ResponseCodeModule,
|
||||
NotificationModule,
|
||||
UserModule,
|
||||
DistributionModule,
|
||||
],
|
||||
providers: [
|
||||
ReviewTeamService,
|
||||
ReviewTaskService,
|
||||
TaskCreationService,
|
||||
AggregateStatusService,
|
||||
ConsensusService,
|
||||
VetoOverrideService,
|
||||
],
|
||||
controllers: [ReviewTeamController],
|
||||
exports: [
|
||||
ReviewTeamService,
|
||||
ReviewTaskService,
|
||||
TaskCreationService,
|
||||
AggregateStatusService,
|
||||
ConsensusService,
|
||||
VetoOverrideService,
|
||||
TypeOrmModule,
|
||||
],
|
||||
})
|
||||
export class ReviewTeamModule {}
|
||||
@@ -0,0 +1,188 @@
|
||||
// File: src/modules/review-team/review-team.service.ts
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTeam } from './entities/review-team.entity';
|
||||
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
import {
|
||||
CreateReviewTeamDto,
|
||||
UpdateReviewTeamDto,
|
||||
AddTeamMemberDto,
|
||||
SearchReviewTeamDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewTeamService {
|
||||
private readonly logger = new Logger(ReviewTeamService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTeam)
|
||||
private readonly teamRepo: Repository<ReviewTeam>,
|
||||
@InjectRepository(ReviewTeamMember)
|
||||
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
@InjectRepository(Discipline)
|
||||
private readonly disciplineRepo: Repository<Discipline>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึง Review Teams ตาม project (FR-001)
|
||||
*/
|
||||
async findAll(dto: SearchReviewTeamDto): Promise<ReviewTeam[]> {
|
||||
const qb = this.teamRepo
|
||||
.createQueryBuilder('team')
|
||||
.leftJoinAndSelect('team.members', 'member')
|
||||
.leftJoinAndSelect('member.user', 'user')
|
||||
.leftJoinAndSelect('member.discipline', 'discipline');
|
||||
|
||||
if (dto.projectPublicId) {
|
||||
qb.innerJoin('team.project', 'project').where('project.uuid = :uuid', {
|
||||
uuid: dto.projectPublicId,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.isActive !== undefined) {
|
||||
qb.andWhere('team.is_active = :isActive', { isActive: dto.isActive });
|
||||
}
|
||||
|
||||
if (dto.search) {
|
||||
qb.andWhere('team.name LIKE :search', { search: `%${dto.search}%` });
|
||||
}
|
||||
|
||||
return qb.orderBy('team.created_at', 'DESC').getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Review Team เดียวตาม publicId (ADR-019)
|
||||
*/
|
||||
async findByPublicId(publicId: string): Promise<ReviewTeam> {
|
||||
const team = await this.teamRepo.findOne({
|
||||
where: { publicId },
|
||||
relations: ['members', 'members.user', 'members.discipline', 'project'],
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Review Team not found: ${publicId}`);
|
||||
}
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Teams ที่เป็น Default สำหรับ RFA type นั้นๆ (FR-002)
|
||||
*/
|
||||
async findDefaultForRfaType(rfaTypeCode: string, projectId: number): Promise<ReviewTeam[]> {
|
||||
const teams = await this.teamRepo.find({
|
||||
where: { projectId, isActive: true },
|
||||
relations: ['members'],
|
||||
});
|
||||
|
||||
return teams.filter(
|
||||
(t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง Review Team ใหม่
|
||||
*/
|
||||
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
|
||||
// ตรวจสอบว่า project มีอยู่จริง (via publicId)
|
||||
const project = await this.teamRepo.manager.getRepository('projects').findOne({
|
||||
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project not found: ${dto.projectPublicId}`);
|
||||
}
|
||||
|
||||
const team = this.teamRepo.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: (project as { id: number }).id,
|
||||
defaultForRfaTypes: dto.defaultForRfaTypes,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return this.teamRepo.save(team);
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดต Review Team
|
||||
*/
|
||||
async update(publicId: string, dto: UpdateReviewTeamDto): Promise<ReviewTeam> {
|
||||
const team = await this.findByPublicId(publicId);
|
||||
|
||||
if (dto.name !== undefined) team.name = dto.name;
|
||||
if (dto.description !== undefined) team.description = dto.description;
|
||||
if (dto.defaultForRfaTypes !== undefined) team.defaultForRfaTypes = dto.defaultForRfaTypes;
|
||||
if (dto.isActive !== undefined) team.isActive = dto.isActive;
|
||||
|
||||
return this.teamRepo.save(team);
|
||||
}
|
||||
|
||||
/**
|
||||
* เพิ่มสมาชิกใน Review Team (FR-001)
|
||||
*/
|
||||
async addMember(teamPublicId: string, dto: AddTeamMemberDto): Promise<ReviewTeamMember> {
|
||||
const team = await this.findByPublicId(teamPublicId);
|
||||
|
||||
// ตรวจสอบ User
|
||||
const user = await this.userRepo.findOne({ where: { publicId: dto.userPublicId } });
|
||||
if (!user) throw new NotFoundException(`User not found: ${dto.userPublicId}`);
|
||||
|
||||
// ตรวจสอบ Discipline
|
||||
const discipline = await this.disciplineRepo.findOne({
|
||||
where: { id: Number(dto.disciplinePublicId) },
|
||||
});
|
||||
if (!discipline) throw new NotFoundException(`Discipline not found: ${dto.disciplinePublicId}`);
|
||||
|
||||
// ตรวจสอบซ้ำ
|
||||
const existing = await this.memberRepo.findOne({
|
||||
where: { teamId: team.id, userId: user.user_id, disciplineId: discipline.id },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const member = this.memberRepo.create({
|
||||
teamId: team.id,
|
||||
userId: user.user_id,
|
||||
disciplineId: discipline.id,
|
||||
role: dto.role,
|
||||
priorityOrder: dto.priorityOrder ?? 0,
|
||||
});
|
||||
|
||||
return this.memberRepo.save(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบสมาชิกออกจาก Review Team
|
||||
*/
|
||||
async removeMember(teamPublicId: string, memberPublicId: string): Promise<void> {
|
||||
const team = await this.findByPublicId(teamPublicId);
|
||||
const member = await this.memberRepo.findOne({
|
||||
where: { publicId: memberPublicId, teamId: team.id },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException(`Member not found: ${memberPublicId}`);
|
||||
}
|
||||
|
||||
await this.memberRepo.remove(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบ Review Team (soft delete ด้วย isActive = false)
|
||||
*/
|
||||
async deactivate(publicId: string): Promise<void> {
|
||||
const team = await this.findByPublicId(publicId);
|
||||
team.isActive = false;
|
||||
await this.teamRepo.save(team);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// File: src/modules/review-team/services/aggregate-status.service.ts
|
||||
// คำนวณสถานะรวมของ Review Tasks ภายใน RFA Revision (T067, FR-009)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { ReviewTaskStatus, ConsensusDecision } from '../../common/enums/review.enums';
|
||||
|
||||
export interface AggregateStatus {
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
delegated: number;
|
||||
expired: number;
|
||||
completionPct: number;
|
||||
isAllComplete: boolean;
|
||||
hasExpired: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AggregateStatusService {
|
||||
private readonly logger = new Logger(AggregateStatusService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly taskRepo: Repository<ReviewTask>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* คำนวณสถานะรวมของทุก Review Tasks ใน RFA Revision (FR-009)
|
||||
*/
|
||||
async getForRevision(rfaRevisionId: number): Promise<AggregateStatus> {
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId },
|
||||
select: ['id', 'status'],
|
||||
});
|
||||
|
||||
const counts = {
|
||||
total: tasks.length,
|
||||
completed: 0,
|
||||
pending: 0,
|
||||
inProgress: 0,
|
||||
delegated: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
for (const task of tasks) {
|
||||
switch (task.status) {
|
||||
case ReviewTaskStatus.COMPLETED: counts.completed++; break;
|
||||
case ReviewTaskStatus.PENDING: counts.pending++; break;
|
||||
case ReviewTaskStatus.IN_PROGRESS: counts.inProgress++; break;
|
||||
case ReviewTaskStatus.DELEGATED: counts.delegated++; break;
|
||||
case ReviewTaskStatus.EXPIRED: counts.expired++; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
const completionPct =
|
||||
counts.total > 0
|
||||
? Math.round((counts.completed / counts.total) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...counts,
|
||||
completionPct,
|
||||
isAllComplete: counts.total > 0 && counts.completed === counts.total,
|
||||
hasExpired: counts.expired > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า RFA Revision พร้อมสำหรับ consensus หรือยัง (FR-010)
|
||||
*/
|
||||
async isReadyForConsensus(rfaRevisionId: number): Promise<boolean> {
|
||||
const status = await this.getForRevision(rfaRevisionId);
|
||||
return status.isAllComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine consensus based on response codes of completed tasks (FR-010)
|
||||
*/
|
||||
async evaluateConsensus(rfaRevisionId: number): Promise<ConsensusDecision> {
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
if (tasks.length === 0) return ConsensusDecision.PENDING;
|
||||
|
||||
// Veto check: any Code 3 = REJECTED
|
||||
const hasVeto = tasks.some((t) => t.responseCode?.code === '3');
|
||||
if (hasVeto) return ConsensusDecision.REJECTED;
|
||||
|
||||
// All approved: Code 1A or 1B = APPROVED
|
||||
const allApproved = tasks.every((t) =>
|
||||
['1A', '1B'].includes(t.responseCode?.code ?? ''),
|
||||
);
|
||||
if (allApproved) return ConsensusDecision.APPROVED;
|
||||
|
||||
// Any Code 2 = APPROVED_WITH_COMMENTS
|
||||
const hasComments = tasks.some((t) => t.responseCode?.code === '2');
|
||||
if (hasComments) return ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||
|
||||
return ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// File: src/modules/review-team/services/consensus.service.ts
|
||||
// Evaluate parallel review consensus และ trigger distribution (T068, FR-010)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { AggregateStatusService } from './aggregate-status.service';
|
||||
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
|
||||
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
export interface ConsensusResult {
|
||||
decision: ConsensusDecision;
|
||||
completedTasks: number;
|
||||
totalTasks: number;
|
||||
triggeredDistribution: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ConsensusService {
|
||||
private readonly logger = new Logger(ConsensusService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly taskRepo: Repository<ReviewTask>,
|
||||
private readonly aggregateStatusService: AggregateStatusService,
|
||||
private readonly approvalListenerService: ApprovalListenerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* เรียกหลัง task complete — ตรวจสอบ consensus และ trigger distribution (FR-010)
|
||||
*/
|
||||
async evaluateAfterTaskComplete(
|
||||
rfaRevisionId: number,
|
||||
context: {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
},
|
||||
): Promise<ConsensusResult> {
|
||||
const isReady = await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId);
|
||||
|
||||
const status = await this.aggregateStatusService.getForRevision(rfaRevisionId);
|
||||
|
||||
if (!isReady) {
|
||||
this.logger.debug(
|
||||
`Revision ${rfaRevisionId}: ${status.completed}/${status.total} tasks done — not ready for consensus`,
|
||||
);
|
||||
return {
|
||||
decision: ConsensusDecision.PENDING,
|
||||
completedTasks: status.completed,
|
||||
totalTasks: status.total,
|
||||
triggeredDistribution: false,
|
||||
};
|
||||
}
|
||||
|
||||
const decision = await this.aggregateStatusService.evaluateConsensus(rfaRevisionId);
|
||||
|
||||
this.logger.log(
|
||||
`Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`,
|
||||
);
|
||||
|
||||
let triggeredDistribution = false;
|
||||
if (
|
||||
decision === ConsensusDecision.APPROVED ||
|
||||
decision === ConsensusDecision.APPROVED_WITH_COMMENTS
|
||||
) {
|
||||
// ดึง response code ที่ predominant
|
||||
const completedTasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||
relations: ['responseCode'],
|
||||
order: { completedAt: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
const responseCode = completedTasks[0]?.responseCode?.code ?? '1A';
|
||||
|
||||
await this.approvalListenerService.onConsensusReached({
|
||||
...context,
|
||||
responseCode,
|
||||
decision,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
|
||||
triggeredDistribution = true;
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
completedTasks: status.completed,
|
||||
totalTasks: status.total,
|
||||
triggeredDistribution,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// File: src/modules/review-team/services/task-creation.service.ts
|
||||
// Strangler Pattern: แยก logic การสร้าง Parallel Review Tasks ออกจาก rfa.service.ts
|
||||
// เรียกใช้หลังจาก RFA Submit สำเร็จ (T017 integration)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, EntityManager } from 'typeorm';
|
||||
import { ReviewTeam } from '../entities/review-team.entity';
|
||||
import { ReviewTeamMember } from '../entities/review-team-member.entity';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
@Injectable()
|
||||
export class TaskCreationService {
|
||||
private readonly logger = new Logger(TaskCreationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTeam)
|
||||
private readonly reviewTeamRepo: Repository<ReviewTeam>,
|
||||
@InjectRepository(ReviewTeamMember)
|
||||
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* สร้าง Parallel Review Tasks สำหรับแต่ละ Discipline ใน Review Team (FR-003)
|
||||
* เรียกใช้ภายใน Transaction ของ rfa.service.ts submit method
|
||||
*
|
||||
* @param rfaRevisionId - Internal ID ของ RFA Revision
|
||||
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
|
||||
* @param dueDate - กำหนดเวลาตรวจสอบ
|
||||
* @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม)
|
||||
*/
|
||||
async createParallelTasks(
|
||||
rfaRevisionId: number,
|
||||
reviewTeamPublicId: string,
|
||||
dueDate: Date,
|
||||
manager: EntityManager,
|
||||
): Promise<ReviewTask[]> {
|
||||
// ดึง ReviewTeam พร้อม members
|
||||
const team = await this.reviewTeamRepo.findOne({
|
||||
where: { publicId: reviewTeamPublicId },
|
||||
relations: ['members'],
|
||||
});
|
||||
|
||||
if (!team || !team.isActive) {
|
||||
this.logger.warn(
|
||||
`ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const members = team.members ?? [];
|
||||
|
||||
if (members.length === 0) {
|
||||
this.logger.warn(
|
||||
`ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// กลุ่ม members ตาม disciplineId (แต่ละ Discipline ต้องการเพียง 1 Task)
|
||||
const disciplineMap = new Map<number, ReviewTeamMember>();
|
||||
for (const member of members) {
|
||||
// LEAD มี priority สูงสุด ถ้ามีหลายคนใน Discipline เดียวกัน
|
||||
const existing = disciplineMap.get(member.disciplineId);
|
||||
if (!existing || member.role === 'LEAD') {
|
||||
disciplineMap.set(member.disciplineId, member);
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: ReviewTask[] = [];
|
||||
|
||||
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
|
||||
for (const [disciplineId, leadMember] of disciplineMap) {
|
||||
const task = manager.create(ReviewTask, {
|
||||
rfaRevisionId,
|
||||
teamId: team.id,
|
||||
disciplineId,
|
||||
assignedToUserId: leadMember.userId,
|
||||
status: ReviewTaskStatus.PENDING,
|
||||
dueDate,
|
||||
});
|
||||
const saved = await manager.save(ReviewTask, task);
|
||||
tasks.push(saved);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`,
|
||||
);
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า RFA Revision มี Review Tasks ครบทุก Discipline แล้วหรือยัง
|
||||
*/
|
||||
async areAllTasksCompleted(rfaRevisionId: number): Promise<boolean> {
|
||||
const tasks = await this.reviewTaskRepo.find({
|
||||
where: { rfaRevisionId },
|
||||
});
|
||||
|
||||
if (tasks.length === 0) return false;
|
||||
|
||||
return tasks.every(
|
||||
(t: ReviewTask) =>
|
||||
t.status === ReviewTaskStatus.COMPLETED ||
|
||||
t.status === ReviewTaskStatus.CANCELLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// File: src/modules/review-team/services/veto-override.service.ts
|
||||
// PM Veto Override — บังคับผ่าน RFA Revision แม้มี Code 3 (T068.5)
|
||||
import { Injectable, Logger, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
|
||||
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
export interface VetoOverrideDto {
|
||||
rfaRevisionId: number;
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
overrideReason: string;
|
||||
overriddenByUserId: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VetoOverrideService {
|
||||
private readonly logger = new Logger(VetoOverrideService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly taskRepo: Repository<ReviewTask>,
|
||||
private readonly approvalListenerService: ApprovalListenerService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* PM Override: บังคับ APPROVED แม้ว่าจะมี Code 3 rejection (FR-012)
|
||||
* ต้องมี justification reason และ audit trail
|
||||
*/
|
||||
async executeOverride(dto: VetoOverrideDto): Promise<{ decision: ConsensusDecision }> {
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId: dto.rfaRevisionId },
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw new NotFoundException(`No review tasks found for revision ${dto.rfaRevisionId}`);
|
||||
}
|
||||
|
||||
const hasVeto = tasks.some((t) => t.responseCode?.code === '3');
|
||||
if (!hasVeto) {
|
||||
throw new ForbiddenException('No Code 3 veto found — override not needed');
|
||||
}
|
||||
|
||||
if (!dto.overrideReason || dto.overrideReason.trim().length < 10) {
|
||||
throw new ForbiddenException('Override reason must be at least 10 characters');
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`,
|
||||
);
|
||||
|
||||
await this.approvalListenerService.onConsensusReached({
|
||||
rfaPublicId: dto.rfaPublicId,
|
||||
rfaRevisionPublicId: dto.rfaRevisionPublicId,
|
||||
projectId: dto.projectId,
|
||||
documentTypeCode: dto.documentTypeCode,
|
||||
responseCode: '1A',
|
||||
decision: ConsensusDecision.OVERRIDDEN,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
|
||||
return { decision: ConsensusDecision.OVERRIDDEN };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user