690513:0920 Refactor Workflow module: Lint error #01
CI / CD Pipeline / build (push) Failing after 10m44s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-13 09:20:49 +07:00
parent e218fc826c
commit 5537d20152
299 changed files with 27326 additions and 2501 deletions
@@ -14,7 +14,10 @@ export function validateDueDate(dueDate: Date): void {
/**
* ตรวจสอบ delegation date range ไม่เกิน 90 วัน
*/
export function validateDelegationDateRange(startDate: Date, endDate: Date): void {
export function validateDelegationDateRange(
startDate: Date,
endDate: Date
): void {
if (endDate <= startDate) {
throw new Error('End date must be after start date');
}
@@ -35,7 +38,7 @@ export function validateTaskCompletionRequirements(
taskStatus: string,
responseCodeId: number | undefined | null,
requiresComments: boolean,
comments: string | undefined | null,
comments: string | undefined | null
): void {
if (taskStatus === 'COMPLETED') {
if (!responseCodeId) {
@@ -54,11 +57,11 @@ export function validateTaskCompletionRequirements(
export function validateVersion(
expectedVersion: number,
actualVersion: number,
entityName: string,
entityName: string
): void {
if (actualVersion !== expectedVersion) {
throw new Error(
`Optimistic lock conflict on ${entityName}: expected version ${expectedVersion}, got ${actualVersion}. Please retry.`,
`Optimistic lock conflict on ${entityName}: expected version ${expectedVersion}, got ${actualVersion}. Please retry.`
);
}
}
@@ -3,67 +3,67 @@
// ─── Review Task Status ────────────────────────────────────────────────────
export enum ReviewTaskStatus {
PENDING = 'PENDING', // รอดำเนินการ
IN_PROGRESS = 'IN_PROGRESS', // กำลังตรวจสอบ
COMPLETED = 'COMPLETED', // เสร็จสิ้น (มีผลลัพธ์)
DELEGATED = 'DELEGATED', // ถูกมอบหมายให้ผู้อื่น
EXPIRED = 'EXPIRED', // เกินกำหนด
CANCELLED = 'CANCELLED', // ยกเลิก
PENDING = 'PENDING', // รอดำเนินการ
IN_PROGRESS = 'IN_PROGRESS', // กำลังตรวจสอบ
COMPLETED = 'COMPLETED', // เสร็จสิ้น (มีผลลัพธ์)
DELEGATED = 'DELEGATED', // ถูกมอบหมายให้ผู้อื่น
EXPIRED = 'EXPIRED', // เกินกำหนด
CANCELLED = 'CANCELLED', // ยกเลิก
}
// ─── Response Code Category ────────────────────────────────────────────────
export enum ResponseCodeCategory {
ENGINEERING = 'ENGINEERING', // Shop Drawing / Method Statement / As-Built
MATERIAL = 'MATERIAL', // Material / Procurement Submittal
CONTRACT = 'CONTRACT', // Contract / Cost / BOQ
TESTING = 'TESTING', // Testing / Handover / QA
ESG = 'ESG', // Environment / Social / Governance
ENGINEERING = 'ENGINEERING', // Shop Drawing / Method Statement / As-Built
MATERIAL = 'MATERIAL', // Material / Procurement Submittal
CONTRACT = 'CONTRACT', // Contract / Cost / BOQ
TESTING = 'TESTING', // Testing / Handover / QA
ESG = 'ESG', // Environment / Social / Governance
}
// ─── Delegation Scope ──────────────────────────────────────────────────────
export enum DelegationScope {
ALL = 'ALL', // มอบหมายทุกงาน
RFA_ONLY = 'RFA_ONLY', // เฉพาะงาน RFA
ALL = 'ALL', // มอบหมายทุกงาน
RFA_ONLY = 'RFA_ONLY', // เฉพาะงาน RFA
CORRESPONDENCE_ONLY = 'CORRESPONDENCE_ONLY', // เฉพาะงาน Correspondence
SPECIFIC_TYPES = 'SPECIFIC_TYPES', // กำหนดประเภทเอกสารเอง
SPECIFIC_TYPES = 'SPECIFIC_TYPES', // กำหนดประเภทเอกสารเอง
}
// ─── Reminder Type ─────────────────────────────────────────────────────────
export enum ReminderType {
DUE_SOON = 'DUE_SOON', // X วันก่อนครบกำหนด
ON_DUE = 'ON_DUE', // วันครบกำหนด
OVERDUE = 'OVERDUE', // หลังครบกำหนด (ส่งซ้ำทุกวัน)
ESCALATION_L1 = 'ESCALATION_L1', // Escalation ระดับ 1 (ถึง Manager)
ESCALATION_L2 = 'ESCALATION_L2', // Escalation ระดับ 2 (ถึง PM/Director)
DUE_SOON = 'DUE_SOON', // X วันก่อนครบกำหนด
ON_DUE = 'ON_DUE', // วันครบกำหนด
OVERDUE = 'OVERDUE', // หลังครบกำหนด (ส่งซ้ำทุกวัน)
ESCALATION_L1 = 'ESCALATION_L1', // Escalation ระดับ 1 (ถึง Manager)
ESCALATION_L2 = 'ESCALATION_L2', // Escalation ระดับ 2 (ถึง PM/Director)
}
// ─── Review Team Member Role ───────────────────────────────────────────────
export enum ReviewTeamMemberRole {
REVIEWER = 'REVIEWER', // ผู้ตรวจสอบ
LEAD = 'LEAD', // หัวหน้าทีม (Lead Reviewer)
MANAGER = 'MANAGER', // ผู้จัดการ (Escalation target)
REVIEWER = 'REVIEWER', // ผู้ตรวจสอบ
LEAD = 'LEAD', // หัวหน้าทีม (Lead Reviewer)
MANAGER = 'MANAGER', // ผู้จัดการ (Escalation target)
}
// ─── Distribution Recipient Type ──────────────────────────────────────────
export enum RecipientType {
USER = 'USER', // ผู้ใช้เฉพาะคน
USER = 'USER', // ผู้ใช้เฉพาะคน
ORGANIZATION = 'ORGANIZATION', // องค์กร
TEAM = 'TEAM', // ทีม
ROLE = 'ROLE', // บทบาท เช่น ALL_QS, ALL_SITE_ENG
TEAM = 'TEAM', // ทีม
ROLE = 'ROLE', // บทบาท เช่น ALL_QS, ALL_SITE_ENG
}
// ─── Distribution Delivery Method ─────────────────────────────────────────
export enum DeliveryMethod {
EMAIL = 'EMAIL', // ส่งอีเมล
IN_APP = 'IN_APP', // แจ้งเตือนในระบบ
BOTH = 'BOTH', // ทั้งสองช่องทาง
EMAIL = 'EMAIL', // ส่งอีเมล
IN_APP = 'IN_APP', // แจ้งเตือนในระบบ
BOTH = 'BOTH', // ทั้งสองช่องทาง
}
// ─── Consensus Decision (Parallel Review) ─────────────────────────────────
export enum ConsensusDecision {
APPROVED = 'APPROVED', // ผ่าน (Majority approved)
REJECTED = 'REJECTED', // ไม่ผ่าน (Veto triggered by Code 3)
APPROVED = 'APPROVED', // ผ่าน (Majority approved)
REJECTED = 'REJECTED', // ไม่ผ่าน (Veto triggered by Code 3)
APPROVED_WITH_COMMENTS = 'APPROVED_WITH_COMMENTS', // ผ่านพร้อมหมายเหตุ
PENDING = 'PENDING', // รอผล (ยังไม่ครบทุก Discipline)
OVERRIDDEN = 'OVERRIDDEN', // PM Override — บังคับผ่าน
PENDING = 'PENDING', // รอผล (ยังไม่ครบทุก Discipline)
OVERRIDDEN = 'OVERRIDDEN', // PM Override — บังคับผ่าน
}
@@ -1,5 +1,13 @@
// File: src/modules/delegation/delegation.controller.ts
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
@@ -25,7 +33,7 @@ export class DelegationController {
* สร้าง Delegation ใหม่ (FR-011)
*/
@Post()
create(@CurrentUser() user: User, @Body() dto: CreateDelegationDto) { // eslint-disable-line @typescript-eslint/no-unused-vars
create(@CurrentUser() user: User, @Body() dto: CreateDelegationDto) {
return this.delegationService.create(user.publicId, dto);
}
@@ -9,10 +9,7 @@ import { CircularDetectionService } from './services/circular-detection.service'
import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([Delegation, User]),
UserModule,
],
imports: [TypeOrmModule.forFeature([Delegation, User]), UserModule],
providers: [DelegationService, CircularDetectionService],
controllers: [DelegationController],
exports: [DelegationService],
@@ -1,5 +1,10 @@
// File: src/modules/delegation/delegation.service.ts
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Delegation } from './entities/delegation.entity';
@@ -16,18 +21,29 @@ export class DelegationService {
private readonly delegationRepo: Repository<Delegation>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
private readonly circularDetectionService: CircularDetectionService,
private readonly circularDetectionService: CircularDetectionService
) {}
/**
* สร้าง Delegation ใหม่ พร้อมตรวจสอบ Circular (FR-011, FR-012)
*/
async create(delegatorPublicId: string, dto: CreateDelegationDto): Promise<Delegation> {
const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
if (!delegator) throw new NotFoundException(`User not found: ${delegatorPublicId}`);
async create(
delegatorPublicId: string,
dto: CreateDelegationDto
): Promise<Delegation> {
const delegator = await this.userRepo.findOne({
where: { publicId: delegatorPublicId },
});
if (!delegator)
throw new NotFoundException(`User not found: ${delegatorPublicId}`);
const delegate = await this.userRepo.findOne({ where: { publicId: dto.delegateUserPublicId } });
if (!delegate) throw new NotFoundException(`Delegate user not found: ${dto.delegateUserPublicId}`);
const delegate = await this.userRepo.findOne({
where: { publicId: dto.delegateUserPublicId },
});
if (!delegate)
throw new NotFoundException(
`Delegate user not found: ${dto.delegateUserPublicId}`
);
// ตรวจสอบ date range
if (dto.startDate >= dto.endDate) {
@@ -38,12 +54,12 @@ export class DelegationService {
const isCircular = await this.circularDetectionService.wouldCreateCircle(
delegator.user_id,
delegate.user_id,
dto.startDate,
dto.startDate
);
if (isCircular) {
throw new BadRequestException(
'Circular delegation detected — this would create a delegation loop',
'Circular delegation detected — this would create a delegation loop'
);
}
@@ -64,7 +80,9 @@ export class DelegationService {
* ดึง Delegations ของ User ทั้งหมด (ในฐานะผู้มอบหมาย)
*/
async findByDelegator(delegatorPublicId: string): Promise<Delegation[]> {
const user = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
const user = await this.userRepo.findOne({
where: { publicId: delegatorPublicId },
});
if (!user) throw new NotFoundException(delegatorPublicId);
return this.delegationRepo.find({
@@ -78,7 +96,10 @@ export class DelegationService {
* ดึง Active Delegations สำหรับ User ณ วันที่กำหนด (FR-013)
* ใช้ใน ReviewTaskService ก่อน assign task
*/
async findActiveDelegate(userId: number, date: Date = new Date()): Promise<User | null> {
async findActiveDelegate(
userId: number,
date: Date = new Date()
): Promise<User | null> {
const delegation = await this.delegationRepo
.createQueryBuilder('d')
.innerJoinAndSelect('d.delegate', 'delegate')
@@ -100,10 +121,13 @@ export class DelegationService {
where: { publicId },
});
if (!delegation) throw new NotFoundException(`Delegation not found: ${publicId}`);
if (!delegation)
throw new NotFoundException(`Delegation not found: ${publicId}`);
// ตรวจสอบ ownership
const delegator = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
const delegator = await this.userRepo.findOne({
where: { publicId: delegatorPublicId },
});
if (!delegator || delegation.delegatorUserId !== delegator.user_id) {
throw new BadRequestException('You can only revoke your own delegations');
}
@@ -1,5 +1,12 @@
// File: src/modules/delegation/dto/create-delegation.dto.ts
import { IsDate, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import {
IsDate,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { DelegationScope } from '../../common/enums/review.enums';
@@ -9,7 +9,7 @@ import { Delegation } from '../entities/delegation.entity';
export class CircularDetectionService {
constructor(
@InjectRepository(Delegation)
private readonly delegationRepo: Repository<Delegation>,
private readonly delegationRepo: Repository<Delegation>
) {}
/**
@@ -24,7 +24,7 @@ export class CircularDetectionService {
async wouldCreateCircle(
proposedFrom: number,
proposedTo: number,
today: Date = new Date(),
today: Date = new Date()
): Promise<boolean> {
// ถ้า A→B และ proposedFrom=B, proposedTo=A → circular ชัดเจน
if (proposedFrom === proposedTo) return true;
@@ -57,7 +57,7 @@ export class CircularDetectionService {
current: number,
target: number,
graph: Map<number, number[]>,
visited: Set<number>,
visited: Set<number>
): boolean {
if (current === target) return true;
if (visited.has(current)) return false;
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DistributionMatrix } from './entities/distribution-matrix.entity';
import { DistributionRecipient } from './entities/distribution-recipient.entity';
import { Project } from '../project/entities/project.entity';
export interface CreateDistributionMatrixDto {
projectId: number;
@@ -29,6 +30,8 @@ export class DistributionMatrixService {
private readonly matrixRepo: Repository<DistributionMatrix>,
@InjectRepository(DistributionRecipient)
private readonly recipientRepo: Repository<DistributionRecipient>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>
) {}
async findByProject(projectId: number): Promise<DistributionMatrix[]> {
@@ -39,9 +42,20 @@ export class DistributionMatrixService {
});
}
async findByProjectPublicId(
projectPublicId: string
): Promise<DistributionMatrix[]> {
const project = await this.projectRepo.findOne({
where: { publicId: projectPublicId },
});
if (!project)
throw new NotFoundException(`Project not found: ${projectPublicId}`);
return this.findByProject(project.id);
}
async findOneByDocType(
projectId: number,
documentTypeCode: string,
documentTypeCode: string
): Promise<DistributionMatrix | null> {
return this.matrixRepo.findOne({
where: { projectId, documentTypeCode, isActive: true },
@@ -56,10 +70,13 @@ export class DistributionMatrixService {
async addRecipient(
matrixPublicId: string,
dto: AddRecipientDto,
dto: AddRecipientDto
): Promise<DistributionRecipient> {
const matrix = await this.matrixRepo.findOne({ where: { publicId: matrixPublicId } });
if (!matrix) throw new NotFoundException(`Matrix not found: ${matrixPublicId}`);
const matrix = await this.matrixRepo.findOne({
where: { publicId: matrixPublicId },
});
if (!matrix)
throw new NotFoundException(`Matrix not found: ${matrixPublicId}`);
const recipient = this.recipientRepo.create({
matrixId: matrix.id,
@@ -70,7 +87,9 @@ export class DistributionMatrixService {
}
async removeRecipient(recipientPublicId: string): Promise<void> {
const recipient = await this.recipientRepo.findOne({ where: { publicId: recipientPublicId } });
const recipient = await this.recipientRepo.findOne({
where: { publicId: recipientPublicId },
});
if (!recipient) throw new NotFoundException(recipientPublicId);
await this.recipientRepo.remove(recipient);
}
@@ -1,7 +1,17 @@
// File: src/modules/distribution/distribution.controller.ts
// Admin endpoints สำหรับจัดการ Distribution Matrix (T058)
import { Controller, Get, Post, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { DistributionMatrixService } from './distribution-matrix.service';
class CreateMatrixDto {
@@ -24,8 +34,10 @@ export class DistributionController {
constructor(private readonly matrixService: DistributionMatrixService) {}
@Get()
findByProject(@Query('projectId') projectId: string) {
return this.matrixService.findByProject(parseInt(projectId, 10));
findByProject(
@Query('projectPublicId', ParseUuidPipe) projectPublicId: string
) {
return this.matrixService.findByProjectPublicId(projectPublicId);
}
@Post()
@@ -34,7 +46,10 @@ export class DistributionController {
}
@Post(':publicId/recipients')
addRecipient(@Param('publicId') publicId: string, @Body() dto: AddRecipientDto) {
addRecipient(
@Param('publicId') publicId: string,
@Body() dto: AddRecipientDto
) {
return this.matrixService.addRecipient(publicId, dto);
}
@@ -12,10 +12,15 @@ import { ApprovalListenerService } from './services/approval-listener.service';
import { TransmittalCreatorService } from './services/transmittal-creator.service';
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
import { NotificationModule } from '../notification/notification.module';
import { Project } from '../project/entities/project.entity';
@Module({
imports: [
TypeOrmModule.forFeature([DistributionMatrix, DistributionRecipient]),
TypeOrmModule.forFeature([
DistributionMatrix,
DistributionRecipient,
Project,
]),
BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }),
NotificationModule,
],
@@ -27,6 +32,10 @@ import { NotificationModule } from '../notification/notification.module';
TransmittalCreatorService,
],
controllers: [DistributionController],
exports: [DistributionService, DistributionMatrixService, ApprovalListenerService],
exports: [
DistributionService,
DistributionMatrixService,
ApprovalListenerService,
],
})
export class DistributionModule {}
@@ -20,7 +20,7 @@ export class DistributionService {
constructor(
@InjectQueue(QUEUE_DISTRIBUTION)
private readonly distributionQueue: Queue,
private readonly distributionQueue: Queue
) {}
/**
@@ -35,14 +35,16 @@ export class DistributionService {
});
this.logger.log(
`Distribution queued for RFA ${payload.rfaPublicId} (code: ${payload.responseCode})`,
`Distribution queued for RFA ${payload.rfaPublicId} (code: ${payload.responseCode})`
);
}
/**
* ตรวจสอบสถานะ distribution jobs ของ RFA
*/
async getJobStatus(rfaPublicId: string): Promise<{ pending: number; completed: number }> {
async getJobStatus(
_rfaPublicId: string
): Promise<{ pending: number; completed: number }> {
const [waiting, active] = await Promise.all([
this.distributionQueue.getWaitingCount(),
this.distributionQueue.getActiveCount(),
@@ -27,7 +27,11 @@ export class DistributionMatrix extends UuidBaseEntity {
@Column({ name: 'document_type_code', length: 20 })
documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'...
@Column({ name: 'response_code_filter', type: 'simple-array', nullable: true })
@Column({
name: 'response_code_filter',
type: 'simple-array',
nullable: true,
})
responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
@@ -44,6 +48,10 @@ export class DistributionMatrix extends UuidBaseEntity {
@JoinColumn({ name: 'project_id' })
project?: Project;
@OneToMany(() => DistributionRecipient, (r: DistributionRecipient) => r.matrix, { cascade: true })
@OneToMany(
() => DistributionRecipient,
(r: DistributionRecipient) => r.matrix,
{ cascade: true }
)
recipients?: DistributionRecipient[];
}
@@ -49,7 +49,11 @@ export class DistributionRecipient extends UuidBaseEntity {
createdAt!: Date;
// Relations
@ManyToOne(() => DistributionMatrix, (m: DistributionMatrix) => m.recipients, { onDelete: 'CASCADE' })
@ManyToOne(
() => DistributionMatrix,
(m: DistributionMatrix) => m.recipients,
{ onDelete: 'CASCADE' }
)
@JoinColumn({ name: 'matrix_id' })
matrix!: DistributionMatrix;
}
@@ -14,7 +14,7 @@ export class DistributionProcessor extends WorkerHost {
constructor(
private readonly transmittalCreator: TransmittalCreatorService,
private readonly notificationService: NotificationService,
private readonly notificationService: NotificationService
) {
super();
}
@@ -23,14 +23,15 @@ export class DistributionProcessor extends WorkerHost {
const payload = job.data;
this.logger.log(
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`,
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`
);
// 1. สร้าง Transmittal records
const result = await this.transmittalCreator.createFromDistribution(payload);
const result =
await this.transmittalCreator.createFromDistribution(payload);
this.logger.log(
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`,
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`
);
// 2. แจ้งเตือน submitter
@@ -1,7 +1,10 @@
// File: src/modules/distribution/services/approval-listener.service.ts
// Strangler Pattern — listens for RFA approval events and triggers distribution (T055)
import { Injectable, Logger } from '@nestjs/common';
import { DistributionService, DistributionJobPayload } from '../distribution.service';
import {
DistributionService,
DistributionJobPayload,
} from '../distribution.service';
import { ConsensusDecision } from '../../common/enums/review.enums';
/**
@@ -33,7 +36,7 @@ export class ApprovalListenerService {
if (!shouldDistribute) {
this.logger.log(
`RFA ${event.rfaPublicId} decision = ${event.decision} — distribution skipped`,
`RFA ${event.rfaPublicId} decision = ${event.decision} — distribution skipped`
);
return;
}
@@ -50,7 +53,7 @@ export class ApprovalListenerService {
await this.distributionService.queueDistribution(payload);
this.logger.log(
`Distribution triggered for RFA ${event.rfaPublicId} (${event.decision})`,
`Distribution triggered for RFA ${event.rfaPublicId} (${event.decision})`
);
}
}
@@ -15,7 +15,7 @@ export class TransmittalCreatorService {
constructor(
@InjectRepository(DistributionMatrix)
private readonly matrixRepo: Repository<DistributionMatrix>,
private readonly matrixRepo: Repository<DistributionMatrix>
) {}
/**
@@ -40,7 +40,7 @@ export class TransmittalCreatorService {
if (!matrix || !matrix.recipients || matrix.recipients.length === 0) {
this.logger.log(
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}`,
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}`
);
return { transmittalPublicIds: [] };
}
@@ -52,13 +52,13 @@ export class TransmittalCreatorService {
!matrix.responseCodeFilter.includes(payload.responseCode)
) {
this.logger.log(
`Response code ${payload.responseCode} not in filter — skipping distribution`,
`Response code ${payload.responseCode} not in filter — skipping distribution`
);
return { transmittalPublicIds: [] };
}
this.logger.log(
`Creating Transmittal for RFA ${payload.rfaPublicId}${matrix.recipients.length} recipients`,
`Creating Transmittal for RFA ${payload.rfaPublicId}${matrix.recipients.length} recipients`
);
// TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป
@@ -1,5 +1,12 @@
// File: src/modules/reminder/dto/create-reminder-rule.dto.ts
import { IsEnum, IsInt, IsOptional, IsString, IsArray, MaxLength } from 'class-validator';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
IsArray,
MaxLength,
} from 'class-validator';
import { ReminderType } from '../../common/enums/review.enums';
export class CreateReminderRuleDto {
@@ -15,7 +15,7 @@ export class ReminderProcessor extends WorkerHost {
constructor(
private readonly escalationService: EscalationService,
private readonly notificationService: NotificationService,
private readonly notificationService: NotificationService
) {
super();
}
@@ -23,14 +23,17 @@ export class ReminderProcessor extends WorkerHost {
async process(job: Job<ScheduleReminderPayload>): Promise<void> {
const { taskPublicId, assigneeUserId, reminderType } = job.data;
this.logger.log(`Processing reminder job: ${reminderType} for task ${taskPublicId}`);
this.logger.log(
`Processing reminder job: ${reminderType} for task ${taskPublicId}`
);
switch (reminderType) {
case ReminderType.DUE_SOON:
await this.notificationService.send({
userId: assigneeUserId,
title: '⏰ Review Task Due Soon',
message: 'Your review task is due in 2 days. Please complete your review.',
message:
'Your review task is due in 2 days. Please complete your review.',
type: 'SYSTEM',
entityType: 'review_task',
entityId: taskPublicId as unknown as number,
@@ -41,7 +44,8 @@ export class ReminderProcessor extends WorkerHost {
await this.notificationService.send({
userId: assigneeUserId,
title: '🔔 Review Task Due Today',
message: 'Your review task is due today. Please complete it as soon as possible.',
message:
'Your review task is due today. Please complete it as soon as possible.',
type: 'SYSTEM',
entityType: 'review_task',
entityId: taskPublicId as unknown as number,
@@ -52,7 +56,8 @@ export class ReminderProcessor extends WorkerHost {
await this.notificationService.send({
userId: assigneeUserId,
title: '🚨 Review Task Overdue',
message: 'Your review task is overdue. Escalation will occur if not completed.',
message:
'Your review task is overdue. Escalation will occur if not completed.',
type: 'SYSTEM',
entityType: 'review_task',
entityId: taskPublicId as unknown as number,
@@ -1,6 +1,16 @@
// File: src/modules/reminder/reminder.controller.ts
// Admin endpoints สำหรับจัดการ Reminder Rules (T048)
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ReminderService } from './reminder.service';
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
@@ -11,8 +21,8 @@ export class ReminderController {
constructor(private readonly reminderService: ReminderService) {}
@Get()
findAll(@Query('projectId') projectId?: string) {
return this.reminderService.findAll(projectId ? parseInt(projectId, 10) : undefined);
findAll(@Query('projectPublicId') projectPublicId?: string) {
return this.reminderService.findAllByProjectPublicId(projectPublicId);
}
@Get(':publicId')
@@ -26,7 +36,10 @@ export class ReminderController {
}
@Patch(':publicId')
update(@Param('publicId') publicId: string, @Body() dto: Partial<CreateReminderRuleDto>): Promise<unknown> {
update(
@Param('publicId') publicId: string,
@Body() dto: Partial<CreateReminderRuleDto>
): Promise<unknown> {
return this.reminderService.update(publicId, dto);
}
@@ -11,10 +11,11 @@ import { EscalationService } from './services/escalation.service';
import { ReminderProcessor } from './processors/reminder.processor';
import { QUEUE_REMINDERS } from '../common/constants/queue.constants';
import { NotificationModule } from '../notification/notification.module';
import { Project } from '../project/entities/project.entity';
@Module({
imports: [
TypeOrmModule.forFeature([ReminderRule, ReviewTask]),
TypeOrmModule.forFeature([ReminderRule, ReviewTask, Project]),
BullModule.registerQueue({ name: QUEUE_REMINDERS }),
NotificationModule,
],
@@ -1,10 +1,17 @@
// File: src/modules/reminder/reminder.service.ts
// ReminderService — CRUD สำหรับ ReminderRule entities (T044)
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { validate as uuidValidate } from 'uuid';
import { ReminderRule } from './entities/reminder-rule.entity';
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
import { Project } from '../project/entities/project.entity';
export { CreateReminderRuleDto };
@@ -15,6 +22,8 @@ export class ReminderService {
constructor(
@InjectRepository(ReminderRule)
private readonly ruleRepo: Repository<ReminderRule>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>
) {}
async findAll(projectId?: number): Promise<ReminderRule[]> {
@@ -27,9 +36,25 @@ export class ReminderService {
return this.ruleRepo.find({ order: { escalationLevel: 'ASC' } });
}
async findAllByProjectPublicId(
projectPublicId?: string
): Promise<ReminderRule[]> {
if (!projectPublicId) return this.findAll();
if (!uuidValidate(projectPublicId)) {
throw new BadRequestException(`Invalid UUID format: ${projectPublicId}`);
}
const project = await this.projectRepo.findOne({
where: { publicId: projectPublicId },
});
if (!project)
throw new NotFoundException(`Project not found: ${projectPublicId}`);
return this.findAll(project.id);
}
async findOne(publicId: string): Promise<ReminderRule> {
const rule = await this.ruleRepo.findOne({ where: { publicId } });
if (!rule) throw new NotFoundException(`ReminderRule not found: ${publicId}`);
if (!rule)
throw new NotFoundException(`ReminderRule not found: ${publicId}`);
return rule;
}
@@ -38,7 +63,10 @@ export class ReminderService {
return this.ruleRepo.save(rule);
}
async update(publicId: string, dto: Partial<CreateReminderRuleDto>): Promise<ReminderRule> {
async update(
publicId: string,
dto: Partial<CreateReminderRuleDto>
): Promise<ReminderRule> {
const rule = await this.findOne(publicId);
Object.assign(rule, dto);
return this.ruleRepo.save(rule);
@@ -17,7 +17,7 @@ export class EscalationService {
private readonly reviewTaskRepo: Repository<ReviewTask>,
@InjectRepository(ReminderRule)
private readonly reminderRuleRepo: Repository<ReminderRule>,
private readonly notificationService: NotificationService,
private readonly notificationService: NotificationService
) {}
/**
@@ -39,7 +39,7 @@ export class EscalationService {
if (daysOverdue < 1) return;
this.logger.log(
`Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`,
`Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`
);
// แจ้ง Team Lead
@@ -74,11 +74,13 @@ export class EscalationService {
if (daysOverdue < 3) return;
this.logger.warn(
`Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM`,
`Escalation L2: task ${taskPublicId} is ${daysOverdue} days overdue — escalating to PM`
);
// TODO: ดึง PM user ID จาก project membership — ใช้ placeholder สำหรับตอนนี้
this.logger.log(`L2 escalation notification queued for task ${taskPublicId}`);
this.logger.log(
`L2 escalation notification queued for task ${taskPublicId}`
);
}
/**
@@ -15,13 +15,15 @@ export interface ScheduleReminderPayload {
reminderType: ReminderType;
}
type ReminderJob = Job<ScheduleReminderPayload>;
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name);
constructor(
@InjectQueue(QUEUE_REMINDERS)
private readonly reminderQueue: Queue,
private readonly reminderQueue: Queue
) {}
/**
@@ -32,7 +34,8 @@ export class SchedulerService {
const { taskPublicId, dueDate } = payload;
const now = Date.now();
const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> = [];
const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> =
[];
// 2 วันก่อน due date
const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000;
@@ -71,13 +74,13 @@ export class SchedulerService {
this.reminderQueue.add(
'send-reminder',
{ ...payload, reminderType: type },
{ delay: delayMs, removeOnComplete: true, removeOnFail: 100 },
),
),
{ delay: delayMs, removeOnComplete: true, removeOnFail: 100 }
)
)
);
this.logger.log(
`Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`,
`Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`
);
}
@@ -86,12 +89,14 @@ export class SchedulerService {
*/
async cancelForTask(taskPublicId: string): Promise<void> {
const jobs = await this.reminderQueue.getDelayed();
const taskJobs = jobs.filter((j: Job) => j.data?.taskPublicId === taskPublicId);
const taskJobs = jobs.filter(
(j: ReminderJob) => j.data.taskPublicId === taskPublicId
);
await Promise.all(taskJobs.map((j: Job) => j.remove()));
await Promise.all(taskJobs.map((j: ReminderJob) => j.remove()));
this.logger.log(
`Cancelled ${taskJobs.length} reminder jobs for task ${taskPublicId}`,
`Cancelled ${taskJobs.length} reminder jobs for task ${taskPublicId}`
);
}
}
@@ -56,6 +56,9 @@ export class ResponseCode extends UuidBaseEntity {
createdAt!: Date;
// Relations
@OneToMany(() => ResponseCodeRule, (rule: ResponseCodeRule) => rule.responseCode)
@OneToMany(
() => ResponseCodeRule,
(rule: ResponseCodeRule) => rule.responseCode
)
rules?: ResponseCodeRule[];
}
@@ -34,11 +34,11 @@ export class ResponseCodeController {
@Get('document-type/:documentTypeId')
findByDocumentType(
@Param('documentTypeId') documentTypeId: string,
@Query('projectId') projectId?: string,
@Query('projectId') projectId?: string
) {
return this.responseCodeService.findByDocumentType(
Number(documentTypeId),
projectId ? Number(projectId) : undefined,
projectId ? Number(projectId) : undefined
);
}
@@ -14,7 +14,7 @@ export class ResponseCodeService {
@InjectRepository(ResponseCode)
private readonly responseCodeRepo: Repository<ResponseCode>,
@InjectRepository(ResponseCodeRule)
private readonly responseCodeRuleRepo: Repository<ResponseCodeRule>,
private readonly responseCodeRuleRepo: Repository<ResponseCodeRule>
) {}
/**
@@ -31,7 +31,9 @@ export class ResponseCodeService {
* ดึง Response Codes ตาม Category (FR-006)
* ใช้สำหรับแสดงผลใน Review page ตามประเภทเอกสาร
*/
async findByCategory(category: ResponseCodeCategory): Promise<ResponseCode[]> {
async findByCategory(
category: ResponseCodeCategory
): Promise<ResponseCode[]> {
return this.responseCodeRepo.find({
where: { category, isActive: true },
order: { code: 'ASC' },
@@ -44,7 +46,7 @@ export class ResponseCodeService {
*/
async findByDocumentType(
documentTypeId: number,
projectId?: number,
projectId?: number
): Promise<ResponseCode[]> {
// ดึง Rules ระดับ Project (ถ้ามี) หรือ Global default
const rules = await this.responseCodeRuleRepo.find({
@@ -64,7 +66,7 @@ export class ResponseCodeService {
}
return Array.from(codeMap.values()).sort((a, b) =>
a.code.localeCompare(b.code),
a.code.localeCompare(b.code)
);
}
@@ -13,7 +13,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติเพื่อก่อสร้าง',
descriptionEn: 'Approved for Construction',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -21,8 +25,13 @@ export const responseCodeSeedData = [
code: '1B',
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติเพื่อก่อสร้าง พร้อมความเห็น (แก้ไขไม่ต้องส่งกลับ)',
descriptionEn: 'Approved for Construction with Comments (No Resubmission Required)',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
descriptionEn:
'Approved for Construction with Comments (No Resubmission Required)',
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -31,7 +40,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติ — มีผลต่อสัญญา/Change Order',
descriptionEn: 'Approved — Contract Implications / Change Order Required',
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
implications: {
affectsSchedule: true,
affectsCost: true,
requiresContractReview: true,
},
notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER'],
isSystem: true,
},
@@ -40,7 +53,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติทางเลือก — แตกต่างจากแบบสัญญา',
descriptionEn: 'Approved Alternative — Differs from Contract Drawing',
implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true },
implications: {
affectsSchedule: false,
affectsCost: true,
requiresContractReview: true,
},
notifyRoles: ['CONTRACT_MANAGER', 'DESIGN_MANAGER'],
isSystem: true,
},
@@ -49,7 +66,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติเพื่อวัตถุประสงค์การออกแบบเท่านั้น',
descriptionEn: 'Approved for Design Purpose Only',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -58,7 +79,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติเพื่ออ้างอิงเท่านั้น',
descriptionEn: 'Approved for Reference Only',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -67,7 +92,12 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติพร้อมเงื่อนไข ESG',
descriptionEn: 'Approved with ESG Conditions',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
requiresEiaAmendment: true,
},
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'],
isSystem: true,
},
@@ -76,7 +106,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'อนุมัติตามหมายเหตุ — ต้องแก้ไขและส่งกลับเพื่อตรวจสอบ',
descriptionEn: 'Approved as Noted — Revise and Resubmit for Review',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -85,7 +119,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ปฏิเสธ — ต้องแก้ไขและส่งใหม่',
descriptionEn: 'Rejected — Revise and Resubmit',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: ['PROJECT_MANAGER', 'DESIGN_MANAGER'],
isSystem: true,
},
@@ -94,7 +132,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ENGINEERING,
descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน',
descriptionEn: 'Not Applicable / Withdrawn',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -105,7 +147,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.MATERIAL,
descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์เพื่อจัดซื้อ',
descriptionEn: 'Approved for Procurement',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -114,7 +160,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.MATERIAL,
descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์ พร้อมความเห็น',
descriptionEn: 'Approved for Procurement with Comments',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -123,7 +173,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.MATERIAL,
descriptionTh: 'อนุมัติ — มีผลต่อค่าใช้จ่าย',
descriptionEn: 'Approved — Cost Implications',
implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true },
implications: {
affectsSchedule: false,
affectsCost: true,
requiresContractReview: true,
},
notifyRoles: ['QS_MANAGER', 'PROCUREMENT_MANAGER'],
isSystem: true,
},
@@ -132,7 +186,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.MATERIAL,
descriptionTh: 'ส่งข้อมูลเพิ่มเติม',
descriptionEn: 'Provide Additional Information',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -141,7 +199,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.MATERIAL,
descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามสัญญา',
descriptionEn: 'Rejected — Non-Compliant with Contract',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: ['PROJECT_MANAGER', 'PROCUREMENT_MANAGER'],
isSystem: true,
},
@@ -150,7 +212,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.MATERIAL,
descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน',
descriptionEn: 'Not Applicable / Withdrawn',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -161,7 +227,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.CONTRACT,
descriptionTh: 'อนุมัติ — ไม่มีผลต่อสัญญา',
descriptionEn: 'Approved — No Contract Implication',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -170,7 +240,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.CONTRACT,
descriptionTh: 'อนุมัติ — ต้องออก Change Order',
descriptionEn: 'Approved — Change Order Required',
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
implications: {
affectsSchedule: true,
affectsCost: true,
requiresContractReview: true,
},
notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER', 'PROJECT_MANAGER'],
isSystem: true,
},
@@ -179,7 +253,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.CONTRACT,
descriptionTh: 'อยู่ระหว่างการพิจารณา — ต้องการข้อมูลเพิ่มเติม',
descriptionEn: 'Under Review — Additional Information Required',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -188,7 +266,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.CONTRACT,
descriptionTh: 'ปฏิเสธ — ขัดต่อเงื่อนไขสัญญา',
descriptionEn: 'Rejected — Contradicts Contract Terms',
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
implications: {
affectsSchedule: true,
affectsCost: true,
requiresContractReview: true,
},
notifyRoles: ['CONTRACT_MANAGER', 'LEGAL_TEAM', 'PROJECT_MANAGER'],
isSystem: true,
},
@@ -199,7 +281,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.TESTING,
descriptionTh: 'อนุมัติผลการทดสอบ / ส่งมอบ',
descriptionEn: 'Approved — Test Results / Handover Accepted',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -208,7 +294,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.TESTING,
descriptionTh: 'ผ่านพร้อมข้อบกพร่องเล็กน้อย — ต้องแก้ไขและรายงาน',
descriptionEn: 'Passed with Minor Defects — Rectify and Report',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: ['QA_MANAGER'],
isSystem: true,
},
@@ -217,7 +307,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.TESTING,
descriptionTh: 'ไม่ผ่าน — ต้องทดสอบซ้ำ',
descriptionEn: 'Failed — Retest Required',
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false },
implications: {
affectsSchedule: true,
affectsCost: true,
requiresContractReview: false,
},
notifyRoles: ['PROJECT_MANAGER', 'QA_MANAGER'],
isSystem: true,
},
@@ -228,7 +322,11 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ESG,
descriptionTh: 'อนุมัติ — เป็นไปตามมาตรฐาน ESG',
descriptionEn: 'Approved — ESG Compliant',
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
implications: {
affectsSchedule: false,
affectsCost: false,
requiresContractReview: false,
},
notifyRoles: [],
isSystem: true,
},
@@ -237,7 +335,12 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ESG,
descriptionTh: 'อนุมัติพร้อมเงื่อนไขด้านสิ่งแวดล้อม',
descriptionEn: 'Approved with Environmental Conditions',
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true },
implications: {
affectsSchedule: true,
affectsCost: false,
requiresContractReview: false,
requiresEiaAmendment: true,
},
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'],
isSystem: true,
},
@@ -246,7 +349,12 @@ export const responseCodeSeedData = [
category: ResponseCodeCategory.ESG,
descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามข้อกำหนด EIA/ESG',
descriptionEn: 'Rejected — Non-Compliant with EIA/ESG Requirements',
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false, requiresEiaAmendment: true },
implications: {
affectsSchedule: true,
affectsCost: true,
requiresContractReview: false,
requiresEiaAmendment: true,
},
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER', 'PROJECT_MANAGER'],
isSystem: true,
},
@@ -261,13 +369,16 @@ export async function seedResponseCodes(dataSource: DataSource): Promise<void> {
for (const data of responseCodeSeedData) {
const exists = await repo.findOne({
where: { code: data.code, category: data.category as ResponseCodeCategory },
where: {
code: data.code,
category: data.category,
},
});
if (!exists) {
const entity = repo.create({
code: data.code,
category: data.category as ResponseCodeCategory,
category: data.category,
descriptionTh: data.descriptionTh,
descriptionEn: data.descriptionEn,
implications: data.implications,
@@ -35,14 +35,14 @@ export class ImplicationsService {
responseCode.code,
affectsSchedule,
affectsCost,
requiresContractReview,
requiresContractReview
);
const actionRequired = this.buildActionList(
responseCode.code,
requiresContractReview,
requiresEiaAmendment,
affectsCost,
affectsCost
);
return {
@@ -60,7 +60,7 @@ export class ImplicationsService {
code: string,
affectsSchedule: boolean,
affectsCost: boolean,
requiresContractReview: boolean,
requiresContractReview: boolean
): CodeImplicationResult['severity'] {
// Code 3 (Rejected) = CRITICAL เสมอ
if (code === '3') return 'CRITICAL';
@@ -72,7 +72,8 @@ export class ImplicationsService {
if (affectsSchedule && affectsCost) return 'HIGH';
// มีผลต่ออย่างใดอย่างหนึ่ง
if (requiresContractReview || affectsSchedule || affectsCost) return 'MEDIUM';
if (requiresContractReview || affectsSchedule || affectsCost)
return 'MEDIUM';
return 'LOW';
}
@@ -81,7 +82,7 @@ export class ImplicationsService {
code: string,
requiresContractReview: boolean,
requiresEiaAmendment: boolean,
affectsCost: boolean,
affectsCost: boolean
): string[] {
const actions: string[] = [];
@@ -22,7 +22,7 @@ export class InheritanceService {
constructor(
@InjectRepository(ResponseCodeRule)
private readonly ruleRepo: Repository<ResponseCodeRule>,
private readonly ruleRepo: Repository<ResponseCodeRule>
) {}
/**
@@ -34,7 +34,7 @@ export class InheritanceService {
*/
async resolveMatrix(
documentTypeId: number,
projectId?: number,
projectId?: number
): Promise<ResolvedMatrix[]> {
// ดึง global rules (projectId IS NULL)
const globalRules = await this.ruleRepo.find({
@@ -63,7 +63,7 @@ export class InheritanceService {
// Build map: responseCodeId → project rule
const projectRuleMap = new Map(
projectRules.map((r) => [r.responseCodeId, r]),
projectRules.map((r) => [r.responseCodeId, r])
);
// Merge: project overrides global
@@ -96,7 +96,7 @@ export class InheritanceService {
// เพิ่ม project-only rules (ไม่มี global parent)
for (const projectRule of projectRules) {
const alreadyMerged = globalRules.some(
(g) => g.responseCodeId === projectRule.responseCodeId,
(g) => g.responseCodeId === projectRule.responseCodeId
);
if (!alreadyMerged) {
merged.push({
@@ -113,7 +113,7 @@ export class InheritanceService {
}
this.logger.debug(
`Resolved ${merged.length} rules for docType=${documentTypeId}, project=${projectId}`,
`Resolved ${merged.length} rules for docType=${documentTypeId}, project=${projectId}`
);
return merged;
@@ -1,6 +1,11 @@
// File: src/modules/response-code/services/matrix-management.service.ts
// CRUD สำหรับ ResponseCodeRule (global + project overrides) (T061, FR-022)
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ResponseCodeRule } from '../entities/response-code-rule.entity';
@@ -23,7 +28,7 @@ export class MatrixManagementService {
@InjectRepository(ResponseCodeRule)
private readonly ruleRepo: Repository<ResponseCodeRule>,
@InjectRepository(ResponseCode)
private readonly codeRepo: Repository<ResponseCode>,
private readonly codeRepo: Repository<ResponseCode>
) {}
/**
@@ -35,7 +40,9 @@ export class MatrixManagementService {
});
if (!code) {
throw new NotFoundException(`ResponseCode not found: ${dto.responseCodePublicId}`);
throw new NotFoundException(
`ResponseCode not found: ${dto.responseCodePublicId}`
);
}
if (code.isSystem && !dto.isEnabled) {
@@ -52,8 +59,10 @@ export class MatrixManagementService {
if (existing) {
existing.isEnabled = dto.isEnabled;
existing.requiresComments = dto.requiresComments ?? existing.requiresComments;
existing.triggersNotification = dto.triggersNotification ?? existing.triggersNotification;
existing.requiresComments =
dto.requiresComments ?? existing.requiresComments;
existing.triggersNotification =
dto.triggersNotification ?? existing.triggersNotification;
return this.ruleRepo.save(existing);
}
@@ -74,7 +83,7 @@ export class MatrixManagementService {
*/
async getRulesByDocType(
documentTypeId: number,
projectId?: number,
projectId?: number
): Promise<ResponseCodeRule[]> {
const where: Record<string, unknown> = { documentTypeId };
if (projectId !== undefined) {
@@ -93,10 +102,14 @@ export class MatrixManagementService {
* ลบ project override (หวนกลับใช้ global default)
*/
async deleteProjectOverride(rulePublicId: string): Promise<void> {
const rule = await this.ruleRepo.findOne({ where: { publicId: rulePublicId } });
const rule = await this.ruleRepo.findOne({
where: { publicId: rulePublicId },
});
if (!rule) throw new NotFoundException(rulePublicId);
if (!rule.projectId) {
throw new BadRequestException('Cannot delete a global rule — disable it instead');
throw new BadRequestException(
'Cannot delete a global rule — disable it instead'
);
}
await this.ruleRepo.remove(rule);
}
@@ -19,7 +19,7 @@ export class NotificationTriggerService {
@InjectRepository(User)
private readonly userRepo: Repository<User>,
private readonly notificationService: NotificationService,
private readonly implicationsService: ImplicationsService,
private readonly implicationsService: ImplicationsService
) {}
/**
@@ -30,14 +30,16 @@ export class NotificationTriggerService {
responseCodePublicId: string,
rfaPublicId: string,
documentNumber: string,
reviewerUserId: number,
_reviewerUserId: number
): Promise<void> {
const responseCode = await this.responseCodeRepo.findOne({
where: { publicId: responseCodePublicId },
});
if (!responseCode) {
this.logger.warn(`Response code not found for notification trigger: ${responseCodePublicId}`);
this.logger.warn(
`Response code not found for notification trigger: ${responseCodePublicId}`
);
return;
}
@@ -75,12 +77,12 @@ export class NotificationTriggerService {
type: 'SYSTEM',
entityType: 'rfa',
entityId: rfaPublicId as unknown as number,
}),
),
})
)
);
this.logger.log(
`Triggered ${notifyRoles.length} role notifications for code ${codeLabel} on document ${documentNumber}`,
`Triggered ${notifyRoles.length} role notifications for code ${codeLabel} on document ${documentNumber}`
);
}
}
@@ -46,7 +46,9 @@ export class ReviewTeamMember extends UuidBaseEntity {
createdAt!: Date;
// Relations
@ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, { onDelete: 'CASCADE' })
@ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'team_id' })
team!: ReviewTeam;
@@ -30,7 +30,11 @@ export class ReviewTeam extends UuidBaseEntity {
@Column({ length: 255, nullable: true })
description?: string;
@Column({ name: 'default_for_rfa_types', type: 'simple-array', nullable: true })
@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 })
@@ -47,6 +51,10 @@ export class ReviewTeam extends UuidBaseEntity {
@JoinColumn({ name: 'project_id' })
project?: Project;
@OneToMany(() => ReviewTeamMember, (member: ReviewTeamMember) => member.team, { cascade: true })
@OneToMany(
() => ReviewTeamMember,
(member: ReviewTeamMember) => member.team,
{ cascade: true }
)
members?: ReviewTeamMember[];
}
@@ -1,5 +1,11 @@
// File: src/modules/review-team/review-task.service.ts
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
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';
@@ -19,7 +25,7 @@ export class ReviewTaskService {
@InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask>,
@InjectRepository(ResponseCode)
private readonly responseCodeRepo: Repository<ResponseCode>,
private readonly responseCodeRepo: Repository<ResponseCode>
) {}
/**
@@ -41,8 +47,11 @@ export class ReviewTaskService {
.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 });
qb.innerJoin(
'rfa_revisions',
'rev',
'rev.id = task.rfa_revision_id'
).where('rev.uuid = :uuid', { uuid: dto.rfaRevisionPublicId });
}
if (dto.status) {
@@ -50,7 +59,9 @@ export class ReviewTaskService {
}
if (dto.assignedToUserPublicId) {
qb.andWhere('user.uuid = :userUuid', { userUuid: dto.assignedToUserPublicId });
qb.andWhere('user.uuid = :userUuid', {
userUuid: dto.assignedToUserPublicId,
});
}
if (dto.dueDateFrom) {
@@ -94,7 +105,8 @@ export class ReviewTaskService {
const total = tasks.length;
const completed = tasks.filter(
(t: ReviewTask) =>
t.status === ReviewTaskStatus.COMPLETED || t.status === ReviewTaskStatus.CANCELLED,
t.status === ReviewTaskStatus.COMPLETED ||
t.status === ReviewTaskStatus.CANCELLED
).length;
const pending = total - completed;
@@ -114,7 +126,7 @@ export class ReviewTaskService {
if (task.status !== ReviewTaskStatus.PENDING) {
throw new BadRequestException(
`Cannot start review: task is already ${task.status}`,
`Cannot start review: task is already ${task.status}`
);
}
@@ -126,7 +138,10 @@ export class ReviewTaskService {
* บันทึกผลการตรวจสอบ (FR-009, T069)
* ใช้ Optimistic Locking (@VersionColumn) ป้องกัน race condition (ADR-002)
*/
async completeReview(publicId: string, dto: CompleteReviewTaskDto): Promise<ReviewTask> {
async completeReview(
publicId: string,
dto: CompleteReviewTaskDto
): Promise<ReviewTask> {
const task = await this.findByPublicId(publicId);
if (
@@ -134,7 +149,7 @@ export class ReviewTaskService {
task.status === ReviewTaskStatus.CANCELLED
) {
throw new BadRequestException(
`Cannot complete review: task is already ${task.status}`,
`Cannot complete review: task is already ${task.status}`
);
}
@@ -145,7 +160,7 @@ export class ReviewTaskService {
if (!responseCode) {
throw new NotFoundException(
`Response Code not found: ${dto.responseCodePublicId}`,
`Response Code not found: ${dto.responseCodePublicId}`
);
}
@@ -154,7 +169,7 @@ export class ReviewTaskService {
ReviewTaskStatus.COMPLETED,
responseCode.id,
false, // requiresComments checked at controller level via ResponseCodeRule
dto.comments,
dto.comments
);
task.status = ReviewTaskStatus.COMPLETED;
@@ -168,9 +183,12 @@ export class ReviewTaskService {
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')) {
if (
errorMessage.includes('OptimisticLock') ||
errorMessage.includes('version')
) {
throw new ConflictException(
'Review task was modified concurrently. Please refresh and try again.',
'Review task was modified concurrently. Please refresh and try again.'
);
}
throw err;
@@ -56,7 +56,10 @@ export class ReviewTeamController {
* อัปเดต Review Team
*/
@Patch(':publicId')
update(@Param('publicId') publicId: string, @Body() dto: UpdateReviewTeamDto) {
update(
@Param('publicId') publicId: string,
@Body() dto: UpdateReviewTeamDto
) {
return this.reviewTeamService.update(publicId, dto);
}
@@ -65,7 +68,10 @@ export class ReviewTeamController {
* เพิ่มสมาชิก
*/
@Post(':publicId/members')
addMember(@Param('publicId') teamPublicId: string, @Body() dto: AddTeamMemberDto) {
addMember(
@Param('publicId') teamPublicId: string,
@Body() dto: AddTeamMemberDto
) {
return this.reviewTeamService.addMember(teamPublicId, dto);
}
@@ -76,7 +82,7 @@ export class ReviewTeamController {
@Delete(':publicId/members/:memberPublicId')
removeMember(
@Param('publicId') teamPublicId: string,
@Param('memberPublicId') memberPublicId: string,
@Param('memberPublicId') memberPublicId: string
) {
return this.reviewTeamService.removeMember(teamPublicId, memberPublicId);
}
@@ -30,14 +30,23 @@ 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';
import {
QUEUE_REMINDERS,
QUEUE_VETO_NOTIFICATIONS,
} from '../common/constants/queue.constants';
@Module({
imports: [
TypeOrmModule.forFeature([ReviewTeam, ReviewTeamMember, ReviewTask, User, Discipline]),
TypeOrmModule.forFeature([
ReviewTeam,
ReviewTeamMember,
ReviewTask,
User,
Discipline,
]),
BullModule.registerQueue(
{ name: QUEUE_REMINDERS },
{ name: QUEUE_VETO_NOTIFICATIONS },
{ name: QUEUE_VETO_NOTIFICATIONS }
),
ResponseCodeModule,
NotificationModule,
@@ -1,5 +1,10 @@
// File: src/modules/review-team/review-team.service.ts
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ReviewTeam } from './entities/review-team.entity';
@@ -25,7 +30,7 @@ export class ReviewTeamService {
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(Discipline)
private readonly disciplineRepo: Repository<Discipline>,
private readonly disciplineRepo: Repository<Discipline>
) {}
/**
@@ -74,14 +79,17 @@ export class ReviewTeamService {
/**
* ดึง Teams ที่เป็น Default สำหรับ RFA type นั้นๆ (FR-002)
*/
async findDefaultForRfaType(rfaTypeCode: string, projectId: number): Promise<ReviewTeam[]> {
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,
(t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false
);
}
@@ -90,9 +98,11 @@ export class ReviewTeamService {
*/
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>,
});
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}`);
@@ -112,12 +122,16 @@ export class ReviewTeamService {
/**
* อัปเดต Review Team
*/
async update(publicId: string, dto: UpdateReviewTeamDto): Promise<ReviewTeam> {
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.defaultForRfaTypes !== undefined)
team.defaultForRfaTypes = dto.defaultForRfaTypes;
if (dto.isActive !== undefined) team.isActive = dto.isActive;
return this.teamRepo.save(team);
@@ -126,27 +140,40 @@ export class ReviewTeamService {
/**
* เพิ่มสมาชิกใน Review Team (FR-001)
*/
async addMember(teamPublicId: string, dto: AddTeamMemberDto): Promise<ReviewTeamMember> {
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}`);
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}`);
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 },
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}`,
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`
);
}
@@ -164,7 +191,10 @@ export class ReviewTeamService {
/**
* ลบสมาชิกออกจาก Review Team
*/
async removeMember(teamPublicId: string, memberPublicId: string): Promise<void> {
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 },
@@ -4,7 +4,10 @@ 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';
import {
ReviewTaskStatus,
ConsensusDecision,
} from '../../common/enums/review.enums';
export interface AggregateStatus {
total: number;
@@ -24,7 +27,7 @@ export class AggregateStatusService {
constructor(
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>,
private readonly taskRepo: Repository<ReviewTask>
) {}
/**
@@ -47,12 +50,23 @@ export class AggregateStatusService {
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;
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;
}
}
@@ -94,7 +108,7 @@ export class AggregateStatusService {
// All approved: Code 1A or 1B = APPROVED
const allApproved = tasks.every((t) =>
['1A', '1B'].includes(t.responseCode?.code ?? ''),
['1A', '1B'].includes(t.responseCode?.code ?? '')
);
if (allApproved) return ConsensusDecision.APPROVED;
@@ -6,7 +6,10 @@ 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';
import {
ConsensusDecision,
ReviewTaskStatus,
} from '../../common/enums/review.enums';
export interface ConsensusResult {
decision: ConsensusDecision;
@@ -23,7 +26,7 @@ export class ConsensusService {
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>,
private readonly aggregateStatusService: AggregateStatusService,
private readonly approvalListenerService: ApprovalListenerService,
private readonly approvalListenerService: ApprovalListenerService
) {}
/**
@@ -36,15 +39,17 @@ export class ConsensusService {
rfaRevisionPublicId: string;
projectId: number;
documentTypeCode: string;
},
}
): Promise<ConsensusResult> {
const isReady = await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId);
const isReady =
await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId);
const status = await this.aggregateStatusService.getForRevision(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`,
`Revision ${rfaRevisionId}: ${status.completed}/${status.total} tasks done — not ready for consensus`
);
return {
decision: ConsensusDecision.PENDING,
@@ -54,10 +59,11 @@ export class ConsensusService {
};
}
const decision = await this.aggregateStatusService.evaluateConsensus(rfaRevisionId);
const decision =
await this.aggregateStatusService.evaluateConsensus(rfaRevisionId);
this.logger.log(
`Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`,
`Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`
);
let triggeredDistribution = false;
@@ -8,7 +8,10 @@ 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';
import {
ReviewTaskStatus,
ReviewTeamMemberRole,
} from '../../common/enums/review.enums';
@Injectable()
export class TaskCreationService {
@@ -20,7 +23,7 @@ export class TaskCreationService {
@InjectRepository(ReviewTeamMember)
private readonly memberRepo: Repository<ReviewTeamMember>,
@InjectRepository(ReviewTask)
private readonly reviewTaskRepo: Repository<ReviewTask>,
private readonly reviewTaskRepo: Repository<ReviewTask>
) {}
/**
@@ -36,7 +39,7 @@ export class TaskCreationService {
rfaRevisionId: number,
reviewTeamPublicId: string,
dueDate: Date,
manager: EntityManager,
manager: EntityManager
): Promise<ReviewTask[]> {
// ดึง ReviewTeam พร้อม members
const team = await this.reviewTeamRepo.findOne({
@@ -46,7 +49,7 @@ export class TaskCreationService {
if (!team || !team.isActive) {
this.logger.warn(
`ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`,
`ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`
);
return [];
}
@@ -55,7 +58,7 @@ export class TaskCreationService {
if (members.length === 0) {
this.logger.warn(
`ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`,
`ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`
);
return [];
}
@@ -65,7 +68,7 @@ export class TaskCreationService {
for (const member of members) {
// LEAD มี priority สูงสุด ถ้ามีหลายคนใน Discipline เดียวกัน
const existing = disciplineMap.get(member.disciplineId);
if (!existing || member.role === 'LEAD') {
if (!existing || member.role === ReviewTeamMemberRole.LEAD) {
disciplineMap.set(member.disciplineId, member);
}
}
@@ -87,7 +90,7 @@ export class TaskCreationService {
}
this.logger.log(
`Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`,
`Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`
);
return tasks;
@@ -106,7 +109,7 @@ export class TaskCreationService {
return tasks.every(
(t: ReviewTask) =>
t.status === ReviewTaskStatus.COMPLETED ||
t.status === ReviewTaskStatus.CANCELLED,
t.status === ReviewTaskStatus.CANCELLED
);
}
}
@@ -1,11 +1,16 @@
// 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 {
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';
import { ConsensusDecision } from '../../common/enums/review.enums';
export interface VetoOverrideDto {
rfaRevisionId: number;
@@ -25,34 +30,42 @@ export class VetoOverrideService {
@InjectRepository(ReviewTask)
private readonly taskRepo: Repository<ReviewTask>,
private readonly approvalListenerService: ApprovalListenerService,
private readonly dataSource: DataSource,
private readonly dataSource: DataSource
) {}
/**
* PM Override: บังคับ APPROVED แม้ว่าจะมี Code 3 rejection (FR-012)
* ต้องมี justification reason และ audit trail
*/
async executeOverride(dto: VetoOverrideDto): Promise<{ decision: ConsensusDecision }> {
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}`);
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');
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');
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}`,
`PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`
);
await this.approvalListenerService.onConsensusReached({
@@ -45,7 +45,9 @@ export class ParallelGatewayHandler {
return completedBranches.size >= 1;
default:
this.logger.warn(`Unknown completion strategy: ${step.completionStrategy as string}`);
this.logger.warn(
`Unknown completion strategy: ${step.completionStrategy as string}`
);
return false;
}
}
@@ -55,7 +57,7 @@ export class ParallelGatewayHandler {
*/
createContext(
rfaRevisionPublicId: string,
step: ParallelGatewayStep,
step: ParallelGatewayStep
): GatewayExecutionContext {
return {
rfaRevisionPublicId,
@@ -70,14 +72,14 @@ export class ParallelGatewayHandler {
markBranchComplete(
ctx: GatewayExecutionContext,
branchId: string,
step: ParallelGatewayStep,
step: ParallelGatewayStep
): { canAdvance: boolean; completedCount: number } {
ctx.completedBranches.add(branchId);
const canAdvance = this.canAdvance(step, ctx);
this.logger.log(
`Branch ${branchId} complete. ${ctx.completedBranches.size}/${ctx.totalBranches} — canAdvance: ${canAdvance}`,
`Branch ${branchId} complete. ${ctx.completedBranches.size}/${ctx.totalBranches} — canAdvance: ${canAdvance}`
);
return { canAdvance, completedCount: ctx.completedBranches.size };