690514:2019 204-rfa-approval-refactor #01
CI / CD Pipeline / build (push) Successful in 6m1s
CI / CD Pipeline / deploy (push) Failing after 6m42s

This commit is contained in:
2026-05-14 20:19:21 +07:00
parent 07cc6d47b1
commit 0240d80da5
183 changed files with 20050 additions and 1017 deletions
@@ -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,