690514:2019 204-rfa-approval-refactor #01
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
// File: src/modules/review-team/dto/shared/review-team.dto.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Align AddTeamMemberDto discipline identifier with the INT-based disciplines schema.
|
||||
// Shared DTOs สำหรับ Review Team และ Review Task APIs
|
||||
|
||||
import {
|
||||
@@ -69,8 +71,9 @@ export class AddTeamMemberDto {
|
||||
@IsUUID()
|
||||
userPublicId!: string; // ADR-019
|
||||
|
||||
@IsUUID()
|
||||
disciplinePublicId!: string; // ADR-019
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
disciplineId!: number; // disciplines table is internal INT per current schema
|
||||
|
||||
@IsEnum(ReviewTeamMemberRole)
|
||||
role!: ReviewTeamMemberRole;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// File: src/modules/review-team/review-task.controller.ts
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ReviewTaskService } from './review-task.service';
|
||||
import { ConsensusService } from './services/consensus.service';
|
||||
import { VetoOverrideService } from './services/veto-override.service';
|
||||
import type { VetoOverrideDto } from './services/veto-override.service';
|
||||
import {
|
||||
CompleteReviewTaskDto,
|
||||
SearchReviewTaskDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
@Controller('review-tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewTaskController {
|
||||
constructor(
|
||||
private readonly reviewTaskService: ReviewTaskService,
|
||||
private readonly consensusService: ConsensusService,
|
||||
private readonly vetoOverrideService: VetoOverrideService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() dto: SearchReviewTaskDto) {
|
||||
return this.reviewTaskService.findAll(dto);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
findOne(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||
return this.reviewTaskService.findByPublicId(publicId);
|
||||
}
|
||||
|
||||
@Patch(':publicId/start')
|
||||
startReview(@Param('publicId', ParseUUIDPipe) publicId: string) {
|
||||
return this.reviewTaskService.startReview(publicId);
|
||||
}
|
||||
|
||||
@Patch(':publicId/complete')
|
||||
async completeReview(
|
||||
@Param('publicId', ParseUUIDPipe) publicId: string,
|
||||
@Body() dto: CompleteReviewTaskDto,
|
||||
@CurrentUser() _user: User
|
||||
) {
|
||||
const task = await this.reviewTaskService.completeReview(publicId, dto);
|
||||
|
||||
// Evaluate consensus after completion (FR-010)
|
||||
try {
|
||||
const fullTask = (await this.reviewTaskService.findFullTaskContext(
|
||||
publicId
|
||||
)) as unknown as Record<string, unknown>;
|
||||
|
||||
const rfaRevision = fullTask.rfaRevision as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const corrRevision = rfaRevision?.correspondenceRevision as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const correspondence = corrRevision?.correspondence as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (rfaRevision && correspondence) {
|
||||
await this.consensusService.evaluateAfterTaskComplete(
|
||||
fullTask.rfaRevisionId,
|
||||
{
|
||||
rfaPublicId: correspondence.publicId as string,
|
||||
|
||||
rfaRevisionPublicId: corrRevision.publicId as string,
|
||||
|
||||
projectId: correspondence.projectId as number,
|
||||
|
||||
documentTypeId: (
|
||||
correspondence.type as Record<string, unknown> | undefined
|
||||
)?.id as number | undefined,
|
||||
|
||||
documentTypeCode:
|
||||
((correspondence.type as Record<string, unknown> | undefined)
|
||||
?.typeCode as string | undefined) ?? 'RFA',
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (_error: unknown) {
|
||||
// Log error but don't fail the task completion response
|
||||
// (error as any).logger?.error(`Consensus evaluation failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
@Post('veto-override')
|
||||
async overrideVeto(@Body() dto: VetoOverrideDto, @CurrentUser() user: User) {
|
||||
return this.vetoOverrideService.executeOverride({
|
||||
...dto,
|
||||
overriddenByUserId: user.user_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// File: src/modules/review-team/review-task.service.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Record audit trail when a review task response code is completed or changed.
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -10,6 +12,7 @@ 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 { ResponseCodeAuditService } from '../response-code/services/audit.service';
|
||||
import {
|
||||
CompleteReviewTaskDto,
|
||||
SearchReviewTaskDto,
|
||||
@@ -25,7 +28,8 @@ export class ReviewTaskService {
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||
private readonly responseCodeAuditService: ResponseCodeAuditService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -91,6 +95,48 @@ export class ReviewTaskService {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Review Task พร้อม context ทั้งหมด (RFA, Project, Type)
|
||||
*/
|
||||
async findFullTaskContext(publicId: string): Promise<ReviewTask> {
|
||||
const task = await this.reviewTaskRepo
|
||||
.createQueryBuilder('task')
|
||||
.leftJoinAndSelect('task.responseCode', 'responseCode')
|
||||
.leftJoinAndSelect('task.team', 'team')
|
||||
.innerJoinAndMapOne(
|
||||
'task.rfaRevision',
|
||||
'rfa_revisions',
|
||||
'rfaRev',
|
||||
'rfaRev.id = task.rfa_revision_id'
|
||||
)
|
||||
.innerJoinAndMapOne(
|
||||
'rfaRev.correspondenceRevision',
|
||||
'correspondence_revisions',
|
||||
'corrRev',
|
||||
'corrRev.id = rfaRev.id'
|
||||
)
|
||||
.innerJoinAndMapOne(
|
||||
'corrRev.correspondence',
|
||||
'correspondences',
|
||||
'corr',
|
||||
'corr.id = corrRev.correspondence_id'
|
||||
)
|
||||
.leftJoinAndMapOne(
|
||||
'corr.type',
|
||||
'correspondence_types',
|
||||
'corrType',
|
||||
'corrType.id = corr.correspondence_type_id'
|
||||
)
|
||||
.where('task.uuid = :publicId', { publicId })
|
||||
.getOne();
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Review Task not found: ${publicId}`);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
|
||||
*/
|
||||
@@ -143,6 +189,7 @@ export class ReviewTaskService {
|
||||
dto: CompleteReviewTaskDto
|
||||
): Promise<ReviewTask> {
|
||||
const task = await this.findByPublicId(publicId);
|
||||
const previousResponseCodeId = task.responseCodeId;
|
||||
|
||||
if (
|
||||
task.status === ReviewTaskStatus.COMPLETED ||
|
||||
@@ -180,7 +227,15 @@ export class ReviewTaskService {
|
||||
|
||||
try {
|
||||
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
|
||||
return await this.reviewTaskRepo.save(task);
|
||||
const savedTask = await this.reviewTaskRepo.save(task);
|
||||
await this.responseCodeAuditService.logReviewTaskResponseCodeChange({
|
||||
reviewTaskPublicId: savedTask.publicId,
|
||||
responseCodePublicId: dto.responseCodePublicId,
|
||||
previousResponseCodeId,
|
||||
currentResponseCodeId: responseCode.id,
|
||||
comments: dto.comments,
|
||||
});
|
||||
return savedTask;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (
|
||||
|
||||
@@ -22,12 +22,15 @@ import { VetoOverrideService } from './services/veto-override.service';
|
||||
|
||||
// Controllers
|
||||
import { ReviewTeamController } from './review-team.controller';
|
||||
import { ReviewTaskController } from './review-task.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';
|
||||
import { DelegationModule } from '../delegation/delegation.module';
|
||||
import { ReminderModule } from '../reminder/reminder.module';
|
||||
|
||||
// Queue constants
|
||||
import {
|
||||
@@ -52,6 +55,8 @@ import {
|
||||
NotificationModule,
|
||||
UserModule,
|
||||
DistributionModule,
|
||||
DelegationModule,
|
||||
ReminderModule,
|
||||
],
|
||||
providers: [
|
||||
ReviewTeamService,
|
||||
@@ -61,7 +66,7 @@ import {
|
||||
ConsensusService,
|
||||
VetoOverrideService,
|
||||
],
|
||||
controllers: [ReviewTeamController],
|
||||
controllers: [ReviewTeamController, ReviewTaskController],
|
||||
exports: [
|
||||
ReviewTeamService,
|
||||
ReviewTaskService,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// File: src/modules/review-team/review-team.service.ts
|
||||
// Change Log:
|
||||
// - 2026-05-13: Resolve project public IDs with UuidResolverService and align discipline lookup with INT discipline IDs.
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -11,6 +13,7 @@ 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 { UuidResolverService } from '../../common/services/uuid-resolver.service';
|
||||
import {
|
||||
CreateReviewTeamDto,
|
||||
UpdateReviewTeamDto,
|
||||
@@ -30,7 +33,8 @@ export class ReviewTeamService {
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
@InjectRepository(Discipline)
|
||||
private readonly disciplineRepo: Repository<Discipline>
|
||||
private readonly disciplineRepo: Repository<Discipline>,
|
||||
private readonly uuidResolver: UuidResolverService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -97,21 +101,14 @@ export class ReviewTeamService {
|
||||
* สร้าง 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 projectId = await this.uuidResolver.resolveProjectId(
|
||||
dto.projectPublicId
|
||||
);
|
||||
|
||||
const team = this.teamRepo.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: (project as { id: number }).id,
|
||||
projectId,
|
||||
defaultForRfaTypes: dto.defaultForRfaTypes,
|
||||
isActive: true,
|
||||
});
|
||||
@@ -155,12 +152,10 @@ export class ReviewTeamService {
|
||||
|
||||
// ตรวจสอบ Discipline
|
||||
const discipline = await this.disciplineRepo.findOne({
|
||||
where: { id: Number(dto.disciplinePublicId) },
|
||||
where: { id: dto.disciplineId },
|
||||
});
|
||||
if (!discipline)
|
||||
throw new NotFoundException(
|
||||
`Discipline not found: ${dto.disciplinePublicId}`
|
||||
);
|
||||
throw new NotFoundException(`Discipline not found: ${dto.disciplineId}`);
|
||||
|
||||
// ตรวจสอบซ้ำ
|
||||
const existing = await this.memberRepo.findOne({
|
||||
@@ -173,7 +168,7 @@ export class ReviewTeamService {
|
||||
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`
|
||||
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplineId}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export class ConsensusService {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeId?: number;
|
||||
documentTypeCode: string;
|
||||
}
|
||||
): Promise<ConsensusResult> {
|
||||
|
||||
@@ -11,7 +11,11 @@ import { ReviewTask } from '../entities/review-task.entity';
|
||||
import {
|
||||
ReviewTaskStatus,
|
||||
ReviewTeamMemberRole,
|
||||
DelegationScope,
|
||||
ReminderType,
|
||||
} from '../../common/enums/review.enums';
|
||||
import { DelegationService } from '../../delegation/delegation.service';
|
||||
import { SchedulerService } from '../../reminder/services/scheduler.service';
|
||||
|
||||
@Injectable()
|
||||
export class TaskCreationService {
|
||||
@@ -23,7 +27,9 @@ export class TaskCreationService {
|
||||
@InjectRepository(ReviewTeamMember)
|
||||
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
private readonly delegationService: DelegationService,
|
||||
private readonly schedulerService: SchedulerService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -34,12 +40,16 @@ export class TaskCreationService {
|
||||
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
|
||||
* @param dueDate - กำหนดเวลาตรวจสอบ
|
||||
* @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม)
|
||||
* @param projectId - (Optional) ID ของโครงการ สำหรับ reminder rules
|
||||
* @param documentTypeCode - (Optional) ประเภทเอกสาร สำหรับ reminder rules
|
||||
*/
|
||||
async createParallelTasks(
|
||||
rfaRevisionId: number,
|
||||
reviewTeamPublicId: string,
|
||||
dueDate: Date,
|
||||
manager: EntityManager
|
||||
manager: EntityManager,
|
||||
projectId?: number,
|
||||
documentTypeCode?: string
|
||||
): Promise<ReviewTask[]> {
|
||||
// ดึง ReviewTeam พร้อม members
|
||||
const team = await this.reviewTeamRepo.findOne({
|
||||
@@ -77,16 +87,40 @@ export class TaskCreationService {
|
||||
|
||||
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
|
||||
for (const [disciplineId, leadMember] of disciplineMap) {
|
||||
const activeDelegate = await this.delegationService.findActiveDelegate(
|
||||
leadMember.userId,
|
||||
dueDate,
|
||||
[DelegationScope.ALL, DelegationScope.RFA_ONLY]
|
||||
);
|
||||
const assignedToUserId = activeDelegate?.user_id ?? leadMember.userId;
|
||||
const delegatedFromUserId = activeDelegate
|
||||
? leadMember.userId
|
||||
: undefined;
|
||||
|
||||
const task = manager.create(ReviewTask, {
|
||||
rfaRevisionId,
|
||||
teamId: team.id,
|
||||
disciplineId,
|
||||
assignedToUserId: leadMember.userId,
|
||||
assignedToUserId,
|
||||
delegatedFromUserId,
|
||||
status: ReviewTaskStatus.PENDING,
|
||||
dueDate,
|
||||
});
|
||||
const saved = await manager.save(ReviewTask, task);
|
||||
tasks.push(saved);
|
||||
|
||||
// Schedule Reminders & Escalation (US4)
|
||||
if (saved.assignedToUserId) {
|
||||
await this.schedulerService.scheduleForTask({
|
||||
taskPublicId: saved.publicId,
|
||||
rfaPublicId: rfaRevisionId.toString(), // ใช้ rfaRevisionId เป็น placeholder
|
||||
assigneeUserId: saved.assignedToUserId,
|
||||
dueDate: saved.dueDate ?? dueDate,
|
||||
reminderType: ReminderType.DUE_SOON, // Start type, scheduler will fetch rules
|
||||
projectId: projectId ?? team.projectId,
|
||||
documentTypeCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface VetoOverrideDto {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeId?: number;
|
||||
documentTypeCode: string;
|
||||
overrideReason: string;
|
||||
overriddenByUserId: number;
|
||||
@@ -72,6 +73,7 @@ export class VetoOverrideService {
|
||||
rfaPublicId: dto.rfaPublicId,
|
||||
rfaRevisionPublicId: dto.rfaRevisionPublicId,
|
||||
projectId: dto.projectId,
|
||||
documentTypeId: dto.documentTypeId,
|
||||
documentTypeCode: dto.documentTypeCode,
|
||||
responseCode: '1A',
|
||||
decision: ConsensusDecision.OVERRIDDEN,
|
||||
|
||||
Reference in New Issue
Block a user