feat(rfa): complete RFA Approval Refactor - all 9 phases (T001-T080)
Phase 1-2: Setup, SQL schema, enums, queue constants, base entities
Phase 3 (US1): ReviewTeam, ReviewTeamMember, ReviewTask, TaskCreationService
Phase 4 (US2): ResponseCode, ResponseCodeRule, ImplicationsService, NotificationTriggerService
Phase 5 (US3): Delegation entity, CircularDetectionService, DelegationService/Controller/Module
Phase 6 (US4): ReminderRule, SchedulerService, EscalationService, ReminderProcessor, ReminderModule
Phase 7 (US5): DistributionMatrix, DistributionRecipient, ApprovalListenerService (Strangler),
TransmittalCreatorService, DistributionProcessor, DistributionModule
Phase 8 (US6): MatrixManagementService, InheritanceService (global→project override)
Phase 9 (Polish): AggregateStatusService, ConsensusService, VetoOverrideService,
ParallelGatewayHandler, review-validators, optimistic locking in completeReview,
test stubs (unit/integration/e2e), jest.config.js updated for tests/ directory
Frontend: ReviewTaskInbox, ParallelProgress, VetoOverrideDialog, DelegationForm,
DelegatedBadge, MatrixEditor, ProjectOverrideManager, DistributionStatus,
ReminderHistory, ResponseCodeSelector, CodeImplications, CompleteReviewForm,
ReviewTeamForm, ReviewTeamSelector, TeamMemberManager
Closes #1
This commit is contained in:
@@ -53,6 +53,11 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { RagModule } from './modules/rag/rag.module';
|
||||
import { ReviewTeamModule } from './modules/review-team/review-team.module';
|
||||
import { ResponseCodeModule } from './modules/response-code/response-code.module';
|
||||
import { DelegationModule } from './modules/delegation/delegation.module';
|
||||
import { ReminderModule } from './modules/reminder/reminder.module';
|
||||
import { DistributionModule } from './modules/distribution/distribution.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -191,6 +196,11 @@ import { RagModule } from './modules/rag/rag.module';
|
||||
MigrationModule,
|
||||
AiModule,
|
||||
RagModule,
|
||||
ReviewTeamModule,
|
||||
ResponseCodeModule,
|
||||
DelegationModule,
|
||||
ReminderModule,
|
||||
DistributionModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// File: src/common/validators/review-validators.ts
|
||||
// Edge case validators สำหรับ RFA Review workflow (T073)
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า due date ถูกต้อง (ต้องอยู่ในอนาคต)
|
||||
*/
|
||||
export function validateDueDate(dueDate: Date): void {
|
||||
const now = new Date();
|
||||
if (dueDate <= now) {
|
||||
throw new Error('Due date must be in the future');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบ delegation date range ไม่เกิน 90 วัน
|
||||
*/
|
||||
export function validateDelegationDateRange(startDate: Date, endDate: Date): void {
|
||||
if (endDate <= startDate) {
|
||||
throw new Error('End date must be after start date');
|
||||
}
|
||||
|
||||
const maxDays = 90;
|
||||
const diffMs = endDate.getTime() - startDate.getTime();
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffDays > maxDays) {
|
||||
throw new Error(`Delegation period cannot exceed ${maxDays} days`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบ ReviewTask ว่าสามารถ complete ได้ (ต้องมี response code)
|
||||
*/
|
||||
export function validateTaskCompletionRequirements(
|
||||
taskStatus: string,
|
||||
responseCodeId: number | undefined | null,
|
||||
requiresComments: boolean,
|
||||
comments: string | undefined | null,
|
||||
): void {
|
||||
if (taskStatus === 'COMPLETED') {
|
||||
if (!responseCodeId) {
|
||||
throw new Error('Response code is required to complete a review task');
|
||||
}
|
||||
|
||||
if (requiresComments && (!comments || comments.trim().length === 0)) {
|
||||
throw new Error('Comments are required for this response code');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบ version สำหรับ optimistic locking (ADR-002)
|
||||
*/
|
||||
export function validateVersion(
|
||||
expectedVersion: number,
|
||||
actualVersion: number,
|
||||
entityName: string,
|
||||
): void {
|
||||
if (actualVersion !== expectedVersion) {
|
||||
throw new Error(
|
||||
`Optimistic lock conflict on ${entityName}: expected version ${expectedVersion}, got ${actualVersion}. Please retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า override reason มีความยาวเพียงพอ
|
||||
*/
|
||||
export function validateOverrideReason(reason: string, minLength = 10): void {
|
||||
if (!reason || reason.trim().length < minLength) {
|
||||
throw new Error(`Override reason must be at least ${minLength} characters`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// File: src/modules/common/constants/queue.constants.ts
|
||||
// Queue name constants สำหรับ BullMQ (ADR-008)
|
||||
// รวม queue ทั้งหมดของระบบไว้ที่เดียว
|
||||
|
||||
// ─── Existing Queues ───────────────────────────────────────────────────────
|
||||
export const QUEUE_NOTIFICATIONS = 'notifications';
|
||||
export const QUEUE_WORKFLOW_EVENTS = 'workflow-events';
|
||||
|
||||
// ─── New Queues (Feature: 1-rfa-approval-refactor) ────────────────────────
|
||||
|
||||
/** Queue สำหรับ Auto-Reminders และ Escalation (T043-T047) */
|
||||
export const QUEUE_REMINDERS = 'reminders';
|
||||
|
||||
/** Queue สำหรับ Distribution Matrix — กระจายเอกสารหลังอนุมัติ (T054-T056) */
|
||||
export const QUEUE_DISTRIBUTION = 'distribution';
|
||||
|
||||
/** Queue สำหรับ Veto Override Notifications (T068.5) */
|
||||
export const QUEUE_VETO_NOTIFICATIONS = 'veto-notifications';
|
||||
@@ -0,0 +1,69 @@
|
||||
// File: src/modules/common/enums/review.enums.ts
|
||||
// Shared enums สำหรับ RFA Approval Refactor (Feature: 1-rfa-approval-refactor)
|
||||
|
||||
// ─── Review Task Status ────────────────────────────────────────────────────
|
||||
export enum ReviewTaskStatus {
|
||||
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
|
||||
}
|
||||
|
||||
// ─── Delegation Scope ──────────────────────────────────────────────────────
|
||||
export enum DelegationScope {
|
||||
ALL = 'ALL', // มอบหมายทุกงาน
|
||||
RFA_ONLY = 'RFA_ONLY', // เฉพาะงาน RFA
|
||||
CORRESPONDENCE_ONLY = 'CORRESPONDENCE_ONLY', // เฉพาะงาน Correspondence
|
||||
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)
|
||||
}
|
||||
|
||||
// ─── Review Team Member Role ───────────────────────────────────────────────
|
||||
export enum ReviewTeamMemberRole {
|
||||
REVIEWER = 'REVIEWER', // ผู้ตรวจสอบ
|
||||
LEAD = 'LEAD', // หัวหน้าทีม (Lead Reviewer)
|
||||
MANAGER = 'MANAGER', // ผู้จัดการ (Escalation target)
|
||||
}
|
||||
|
||||
// ─── Distribution Recipient Type ──────────────────────────────────────────
|
||||
export enum RecipientType {
|
||||
USER = 'USER', // ผู้ใช้เฉพาะคน
|
||||
ORGANIZATION = 'ORGANIZATION', // องค์กร
|
||||
TEAM = 'TEAM', // ทีม
|
||||
ROLE = 'ROLE', // บทบาท เช่น ALL_QS, ALL_SITE_ENG
|
||||
}
|
||||
|
||||
// ─── Distribution Delivery Method ─────────────────────────────────────────
|
||||
export enum DeliveryMethod {
|
||||
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_WITH_COMMENTS = 'APPROVED_WITH_COMMENTS', // ผ่านพร้อมหมายเหตุ
|
||||
PENDING = 'PENDING', // รอผล (ยังไม่ครบทุก Discipline)
|
||||
OVERRIDDEN = 'OVERRIDDEN', // PM Override — บังคับผ่าน
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// File: src/modules/delegation/delegation.controller.ts
|
||||
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';
|
||||
import { DelegationService } from './delegation.service';
|
||||
import { CreateDelegationDto } from './dto/create-delegation.dto';
|
||||
|
||||
@Controller('delegations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DelegationController {
|
||||
constructor(private readonly delegationService: DelegationService) {}
|
||||
|
||||
/**
|
||||
* GET /delegations
|
||||
* ดึง Delegations ของ User ที่ login อยู่
|
||||
*/
|
||||
@Get()
|
||||
findMyDelegations(@CurrentUser() user: User) {
|
||||
return this.delegationService.findByDelegator(user.publicId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /delegations
|
||||
* สร้าง Delegation ใหม่ (FR-011)
|
||||
*/
|
||||
@Post()
|
||||
create(@CurrentUser() user: User, @Body() dto: CreateDelegationDto) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
return this.delegationService.create(user.publicId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /delegations/:publicId
|
||||
* Revoke delegation
|
||||
*/
|
||||
@Delete(':publicId')
|
||||
revoke(@Param('publicId') publicId: string, @CurrentUser() user: User) {
|
||||
return this.delegationService.revoke(publicId, user.publicId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// File: src/modules/delegation/delegation.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Delegation } from './entities/delegation.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { DelegationService } from './delegation.service';
|
||||
import { DelegationController } from './delegation.controller';
|
||||
import { CircularDetectionService } from './services/circular-detection.service';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Delegation, User]),
|
||||
UserModule,
|
||||
],
|
||||
providers: [DelegationService, CircularDetectionService],
|
||||
controllers: [DelegationController],
|
||||
exports: [DelegationService],
|
||||
})
|
||||
export class DelegationModule {}
|
||||
@@ -0,0 +1,115 @@
|
||||
// File: src/modules/delegation/delegation.service.ts
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Delegation } from './entities/delegation.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { CircularDetectionService } from './services/circular-detection.service';
|
||||
import { CreateDelegationDto } from './dto/create-delegation.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DelegationService {
|
||||
private readonly logger = new Logger(DelegationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Delegation)
|
||||
private readonly delegationRepo: Repository<Delegation>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
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}`);
|
||||
|
||||
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) {
|
||||
throw new BadRequestException('startDate must be before endDate');
|
||||
}
|
||||
|
||||
// ตรวจสอบ Circular Delegation (ADR requirement)
|
||||
const isCircular = await this.circularDetectionService.wouldCreateCircle(
|
||||
delegator.user_id,
|
||||
delegate.user_id,
|
||||
dto.startDate,
|
||||
);
|
||||
|
||||
if (isCircular) {
|
||||
throw new BadRequestException(
|
||||
'Circular delegation detected — this would create a delegation loop',
|
||||
);
|
||||
}
|
||||
|
||||
const delegation = this.delegationRepo.create({
|
||||
delegatorUserId: delegator.user_id,
|
||||
delegateUserId: delegate.user_id,
|
||||
scope: dto.scope,
|
||||
startDate: dto.startDate,
|
||||
endDate: dto.endDate,
|
||||
reason: dto.reason,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return this.delegationRepo.save(delegation);
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Delegations ของ User ทั้งหมด (ในฐานะผู้มอบหมาย)
|
||||
*/
|
||||
async findByDelegator(delegatorPublicId: string): Promise<Delegation[]> {
|
||||
const user = await this.userRepo.findOne({ where: { publicId: delegatorPublicId } });
|
||||
if (!user) throw new NotFoundException(delegatorPublicId);
|
||||
|
||||
return this.delegationRepo.find({
|
||||
where: { delegatorUserId: user.user_id },
|
||||
relations: ['delegate'],
|
||||
order: { startDate: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Active Delegations สำหรับ User ณ วันที่กำหนด (FR-013)
|
||||
* ใช้ใน ReviewTaskService ก่อน assign task
|
||||
*/
|
||||
async findActiveDelegate(userId: number, date: Date = new Date()): Promise<User | null> {
|
||||
const delegation = await this.delegationRepo
|
||||
.createQueryBuilder('d')
|
||||
.innerJoinAndSelect('d.delegate', 'delegate')
|
||||
.where('d.delegator_user_id = :userId', { userId })
|
||||
.andWhere('d.is_active = 1')
|
||||
.andWhere('d.start_date <= :date', { date })
|
||||
.andWhere('d.end_date >= :date', { date })
|
||||
.orderBy('d.created_at', 'DESC')
|
||||
.getOne();
|
||||
|
||||
return delegation?.delegate ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke delegation ก่อนกำหนด
|
||||
*/
|
||||
async revoke(publicId: string, delegatorPublicId: string): Promise<void> {
|
||||
const delegation = await this.delegationRepo.findOne({
|
||||
where: { publicId },
|
||||
});
|
||||
|
||||
if (!delegation) throw new NotFoundException(`Delegation not found: ${publicId}`);
|
||||
|
||||
// ตรวจสอบ ownership
|
||||
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');
|
||||
}
|
||||
|
||||
delegation.isActive = false;
|
||||
delegation.endDate = new Date(); // หยุดทันที
|
||||
await this.delegationRepo.save(delegation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// File: src/modules/delegation/dto/create-delegation.dto.ts
|
||||
import { IsDate, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { DelegationScope } from '../../common/enums/review.enums';
|
||||
|
||||
export { DelegationScope };
|
||||
|
||||
export class CreateDelegationDto {
|
||||
@IsUUID()
|
||||
delegateUserPublicId!: string;
|
||||
|
||||
@IsEnum(DelegationScope)
|
||||
scope!: DelegationScope;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectPublicId?: string;
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
startDate!: Date;
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
endDate!: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// File: src/modules/delegation/entities/delegation.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { DelegationScope } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('delegations')
|
||||
export class Delegation extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'delegator_user_id' })
|
||||
@Exclude()
|
||||
delegatorUserId!: number; // ผู้มอบหมาย (A)
|
||||
|
||||
@Column({ name: 'delegate_user_id' })
|
||||
@Exclude()
|
||||
delegateUserId!: number; // ผู้รับมอบหมาย (B)
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: DelegationScope,
|
||||
default: DelegationScope.ALL,
|
||||
})
|
||||
scope!: DelegationScope;
|
||||
|
||||
@Column({ name: 'project_id', nullable: true })
|
||||
@Exclude()
|
||||
projectId?: number; // NULL = all projects (ถ้า scope = PROJECT)
|
||||
|
||||
@Column({ name: 'start_date', type: 'date' })
|
||||
startDate!: Date;
|
||||
|
||||
@Column({ name: 'end_date', type: 'date' })
|
||||
endDate!: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
reason?: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'delegator_user_id' })
|
||||
delegator?: User;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'delegate_user_id' })
|
||||
delegate?: User;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// File: src/modules/delegation/services/circular-detection.service.ts
|
||||
// ตรวจจับ Circular Delegation (A→B→C→A) ป้องกัน infinite loop (FR-012)
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Delegation } from '../entities/delegation.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CircularDetectionService {
|
||||
constructor(
|
||||
@InjectRepository(Delegation)
|
||||
private readonly delegationRepo: Repository<Delegation>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ตรวจสอบ Circular Delegation ด้วย Depth-First Search
|
||||
* ตัวอย่าง: A→B→C→A จะถูกจับได้เมื่อ proposedFrom=A, proposedTo=B
|
||||
*
|
||||
* @param proposedFrom - delegatorUserId ที่กำลังจะสร้าง delegation
|
||||
* @param proposedTo - delegateUserId ที่กำลังจะสร้าง delegation
|
||||
* @param today - วันที่ตรวจสอบ (default: now)
|
||||
* @returns true ถ้าจะเกิด circular delegation
|
||||
*/
|
||||
async wouldCreateCircle(
|
||||
proposedFrom: number,
|
||||
proposedTo: number,
|
||||
today: Date = new Date(),
|
||||
): Promise<boolean> {
|
||||
// ถ้า A→B และ proposedFrom=B, proposedTo=A → circular ชัดเจน
|
||||
if (proposedFrom === proposedTo) return true;
|
||||
|
||||
// ดึง delegations ที่ active ทั้งหมดในช่วงเวลานั้น
|
||||
const activeDelegations = await this.delegationRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.is_active = 1')
|
||||
.andWhere('d.start_date <= :today', { today })
|
||||
.andWhere('d.end_date >= :today', { today })
|
||||
.select(['d.delegatorUserId', 'd.delegateUserId'])
|
||||
.getMany();
|
||||
|
||||
// สร้าง adjacency list: from → [to, ...]
|
||||
const graph = new Map<number, number[]>();
|
||||
for (const d of activeDelegations) {
|
||||
if (!graph.has(d.delegatorUserId)) graph.set(d.delegatorUserId, []);
|
||||
graph.get(d.delegatorUserId)!.push(d.delegateUserId);
|
||||
}
|
||||
|
||||
// เพิ่ม edge ที่กำลังจะสร้าง
|
||||
if (!graph.has(proposedFrom)) graph.set(proposedFrom, []);
|
||||
graph.get(proposedFrom)!.push(proposedTo);
|
||||
|
||||
// DFS จาก proposedTo เพื่อหา path กลับมาที่ proposedFrom
|
||||
return this.dfsHasCycle(proposedTo, proposedFrom, graph, new Set());
|
||||
}
|
||||
|
||||
private dfsHasCycle(
|
||||
current: number,
|
||||
target: number,
|
||||
graph: Map<number, number[]>,
|
||||
visited: Set<number>,
|
||||
): boolean {
|
||||
if (current === target) return true;
|
||||
if (visited.has(current)) return false;
|
||||
|
||||
visited.add(current);
|
||||
|
||||
const neighbors = graph.get(current) ?? [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (this.dfsHasCycle(neighbor, target, graph, visited)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// File: src/modules/distribution/distribution-matrix.service.ts
|
||||
// CRUD สำหรับ DistributionMatrix และ Recipients (T053)
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DistributionMatrix } from './entities/distribution-matrix.entity';
|
||||
import { DistributionRecipient } from './entities/distribution-recipient.entity';
|
||||
|
||||
export interface CreateDistributionMatrixDto {
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
responseCodeFilter?: string[];
|
||||
}
|
||||
|
||||
export interface AddRecipientDto {
|
||||
recipientType: string;
|
||||
recipientId?: number;
|
||||
roleCode?: string;
|
||||
deliveryMethod?: string;
|
||||
isCc?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DistributionMatrixService {
|
||||
private readonly logger = new Logger(DistributionMatrixService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DistributionMatrix)
|
||||
private readonly matrixRepo: Repository<DistributionMatrix>,
|
||||
@InjectRepository(DistributionRecipient)
|
||||
private readonly recipientRepo: Repository<DistributionRecipient>,
|
||||
) {}
|
||||
|
||||
async findByProject(projectId: number): Promise<DistributionMatrix[]> {
|
||||
return this.matrixRepo.find({
|
||||
where: { projectId, isActive: true },
|
||||
relations: ['recipients'],
|
||||
order: { documentTypeCode: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneByDocType(
|
||||
projectId: number,
|
||||
documentTypeCode: string,
|
||||
): Promise<DistributionMatrix | null> {
|
||||
return this.matrixRepo.findOne({
|
||||
where: { projectId, documentTypeCode, isActive: true },
|
||||
relations: ['recipients'],
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateDistributionMatrixDto): Promise<DistributionMatrix> {
|
||||
const matrix = this.matrixRepo.create(dto as Partial<DistributionMatrix>);
|
||||
return this.matrixRepo.save(matrix);
|
||||
}
|
||||
|
||||
async addRecipient(
|
||||
matrixPublicId: string,
|
||||
dto: AddRecipientDto,
|
||||
): Promise<DistributionRecipient> {
|
||||
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,
|
||||
...dto,
|
||||
} as Partial<DistributionRecipient>);
|
||||
|
||||
return this.recipientRepo.save(recipient);
|
||||
}
|
||||
|
||||
async removeRecipient(recipientPublicId: string): Promise<void> {
|
||||
const recipient = await this.recipientRepo.findOne({ where: { publicId: recipientPublicId } });
|
||||
if (!recipient) throw new NotFoundException(recipientPublicId);
|
||||
await this.recipientRepo.remove(recipient);
|
||||
}
|
||||
|
||||
async remove(publicId: string): Promise<void> {
|
||||
const matrix = await this.matrixRepo.findOne({ where: { publicId } });
|
||||
if (!matrix) throw new NotFoundException(publicId);
|
||||
matrix.isActive = false;
|
||||
await this.matrixRepo.save(matrix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { DistributionMatrixService } from './distribution-matrix.service';
|
||||
|
||||
class CreateMatrixDto {
|
||||
projectId!: number;
|
||||
documentTypeCode!: string;
|
||||
responseCodeFilter?: string[];
|
||||
}
|
||||
|
||||
class AddRecipientDto {
|
||||
recipientType!: string;
|
||||
recipientId?: number;
|
||||
roleCode?: string;
|
||||
deliveryMethod?: string;
|
||||
isCc?: boolean;
|
||||
}
|
||||
|
||||
@Controller('admin/distribution-matrices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DistributionController {
|
||||
constructor(private readonly matrixService: DistributionMatrixService) {}
|
||||
|
||||
@Get()
|
||||
findByProject(@Query('projectId') projectId: string) {
|
||||
return this.matrixService.findByProject(parseInt(projectId, 10));
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateMatrixDto) {
|
||||
return this.matrixService.create(dto);
|
||||
}
|
||||
|
||||
@Post(':publicId/recipients')
|
||||
addRecipient(@Param('publicId') publicId: string, @Body() dto: AddRecipientDto) {
|
||||
return this.matrixService.addRecipient(publicId, dto);
|
||||
}
|
||||
|
||||
@Delete(':publicId/recipients/:recipientPublicId')
|
||||
removeRecipient(@Param('recipientPublicId') recipientPublicId: string) {
|
||||
return this.matrixService.removeRecipient(recipientPublicId);
|
||||
}
|
||||
|
||||
@Delete(':publicId')
|
||||
remove(@Param('publicId') publicId: string) {
|
||||
return this.matrixService.remove(publicId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// File: src/modules/distribution/distribution.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { DistributionMatrix } from './entities/distribution-matrix.entity';
|
||||
import { DistributionRecipient } from './entities/distribution-recipient.entity';
|
||||
import { DistributionService } from './distribution.service';
|
||||
import { DistributionMatrixService } from './distribution-matrix.service';
|
||||
import { DistributionController } from './distribution.controller';
|
||||
import { DistributionProcessor } from './processors/distribution.processor';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([DistributionMatrix, DistributionRecipient]),
|
||||
BullModule.registerQueue({ name: QUEUE_DISTRIBUTION }),
|
||||
NotificationModule,
|
||||
],
|
||||
providers: [
|
||||
DistributionService,
|
||||
DistributionMatrixService,
|
||||
DistributionProcessor,
|
||||
ApprovalListenerService,
|
||||
TransmittalCreatorService,
|
||||
],
|
||||
controllers: [DistributionController],
|
||||
exports: [DistributionService, DistributionMatrixService, ApprovalListenerService],
|
||||
})
|
||||
export class DistributionModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
// File: src/modules/distribution/distribution.service.ts
|
||||
// Enqueue distribution jobs เมื่อ RFA ได้รับการอนุมัติ (T054)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QUEUE_DISTRIBUTION } from '../common/constants/queue.constants';
|
||||
|
||||
export interface DistributionJobPayload {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
responseCode: string;
|
||||
approvedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DistributionService {
|
||||
private readonly logger = new Logger(DistributionService.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_DISTRIBUTION)
|
||||
private readonly distributionQueue: Queue,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue distribution job สำหรับ RFA ที่ผ่านการอนุมัติ (FR-018)
|
||||
*/
|
||||
async queueDistribution(payload: DistributionJobPayload): Promise<void> {
|
||||
await this.distributionQueue.add('process-distribution', payload, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Distribution queued for RFA ${payload.rfaPublicId} (code: ${payload.responseCode})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบสถานะ distribution jobs ของ RFA
|
||||
*/
|
||||
async getJobStatus(rfaPublicId: string): Promise<{ pending: number; completed: number }> {
|
||||
const [waiting, active] = await Promise.all([
|
||||
this.distributionQueue.getWaitingCount(),
|
||||
this.distributionQueue.getActiveCount(),
|
||||
]);
|
||||
|
||||
return { pending: waiting + active, completed: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// File: src/modules/distribution/entities/distribution-matrix.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { DistributionRecipient } from './distribution-recipient.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
|
||||
@Entity('distribution_matrices')
|
||||
export class DistributionMatrix extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'project_id' })
|
||||
@Exclude()
|
||||
projectId!: number;
|
||||
|
||||
@Column({ name: 'document_type_code', length: 20 })
|
||||
documentTypeCode!: string; // 'SDW', 'DDW', 'ADW', 'MS'...
|
||||
|
||||
@Column({ name: 'response_code_filter', type: 'simple-array', nullable: true })
|
||||
responseCodeFilter?: string[]; // ['1A','1B'] — NULL = ทุก code
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project?: Project;
|
||||
|
||||
@OneToMany(() => DistributionRecipient, (r: DistributionRecipient) => r.matrix, { cascade: true })
|
||||
recipients?: DistributionRecipient[];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// File: src/modules/distribution/entities/distribution-recipient.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { DistributionMatrix } from './distribution-matrix.entity';
|
||||
import { RecipientType, DeliveryMethod } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('distribution_recipients')
|
||||
export class DistributionRecipient extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'matrix_id' })
|
||||
@Exclude()
|
||||
matrixId!: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: RecipientType,
|
||||
})
|
||||
recipientType!: RecipientType;
|
||||
|
||||
@Column({ name: 'recipient_id', nullable: true })
|
||||
@Exclude()
|
||||
recipientId?: number; // userId / organizationId / teamId (FK based on type)
|
||||
|
||||
@Column({ name: 'role_code', length: 50, nullable: true })
|
||||
roleCode?: string; // 'ALL_QS', 'ALL_SITE_ENG' (when type = ROLE)
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: DeliveryMethod,
|
||||
default: DeliveryMethod.BOTH,
|
||||
})
|
||||
deliveryMethod!: DeliveryMethod;
|
||||
|
||||
@Column({ name: 'is_cc', type: 'tinyint', default: 0 })
|
||||
isCc!: boolean; // true = CC recipient, false = primary
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => DistributionMatrix, (m: DistributionMatrix) => m.recipients, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'matrix_id' })
|
||||
matrix!: DistributionMatrix;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// File: src/modules/distribution/processors/distribution.processor.ts
|
||||
// BullMQ Worker สำหรับประมวลผล Distribution jobs (T056, ADR-008)
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { QUEUE_DISTRIBUTION } from '../../common/constants/queue.constants';
|
||||
import { DistributionJobPayload } from '../distribution.service';
|
||||
import { TransmittalCreatorService } from '../services/transmittal-creator.service';
|
||||
import { NotificationService } from '../../notification/notification.service';
|
||||
|
||||
@Processor(QUEUE_DISTRIBUTION)
|
||||
export class DistributionProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(DistributionProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly transmittalCreator: TransmittalCreatorService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<DistributionJobPayload>): Promise<void> {
|
||||
const payload = job.data;
|
||||
|
||||
this.logger.log(
|
||||
`Processing distribution for RFA ${payload.rfaPublicId} (${payload.documentTypeCode}, code ${payload.responseCode})`,
|
||||
);
|
||||
|
||||
// 1. สร้าง Transmittal records
|
||||
const result = await this.transmittalCreator.createFromDistribution(payload);
|
||||
|
||||
this.logger.log(
|
||||
`Created ${result.transmittalPublicIds.length} transmittals for RFA ${payload.rfaPublicId}`,
|
||||
);
|
||||
|
||||
// 2. แจ้งเตือน submitter
|
||||
this.logger.log(`Distribution complete for RFA ${payload.rfaPublicId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 { ConsensusDecision } from '../../common/enums/review.enums';
|
||||
|
||||
/**
|
||||
* ApprovalListenerService — ถูกเรียกจาก ReviewTaskService หลัง consensus ถูกตัดสินใจ
|
||||
* ใช้ Strangler Pattern: ไม่แก้ไข rfaService.approve() โดยตรง
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApprovalListenerService {
|
||||
private readonly logger = new Logger(ApprovalListenerService.name);
|
||||
|
||||
constructor(private readonly distributionService: DistributionService) {}
|
||||
|
||||
/**
|
||||
* เรียกเมื่อ consensus ถูกตัดสินใจว่า APPROVED หรือ APPROVED_WITH_COMMENTS (FR-018)
|
||||
*/
|
||||
async onConsensusReached(event: {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
responseCode: string;
|
||||
decision: ConsensusDecision;
|
||||
approvedAt: Date;
|
||||
}): Promise<void> {
|
||||
const shouldDistribute =
|
||||
event.decision === ConsensusDecision.APPROVED ||
|
||||
event.decision === ConsensusDecision.APPROVED_WITH_COMMENTS ||
|
||||
event.decision === ConsensusDecision.OVERRIDDEN;
|
||||
|
||||
if (!shouldDistribute) {
|
||||
this.logger.log(
|
||||
`RFA ${event.rfaPublicId} decision = ${event.decision} — distribution skipped`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: DistributionJobPayload = {
|
||||
rfaPublicId: event.rfaPublicId,
|
||||
rfaRevisionPublicId: event.rfaRevisionPublicId,
|
||||
projectId: event.projectId,
|
||||
documentTypeCode: event.documentTypeCode,
|
||||
responseCode: event.responseCode,
|
||||
approvedAt: event.approvedAt,
|
||||
};
|
||||
|
||||
await this.distributionService.queueDistribution(payload);
|
||||
|
||||
this.logger.log(
|
||||
`Distribution triggered for RFA ${event.rfaPublicId} (${event.decision})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// File: src/modules/distribution/services/transmittal-creator.service.ts
|
||||
// สร้าง Transmittal records จาก Distribution jobs (T057)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DistributionMatrix } from '../entities/distribution-matrix.entity';
|
||||
|
||||
/**
|
||||
* TransmittalCreatorService — ใช้ Strangler Pattern ไม่แก้ไข TransmittalService เดิม
|
||||
* สร้าง Transmittal ผ่าน existing TransmittalService หลัง distribution
|
||||
*/
|
||||
@Injectable()
|
||||
export class TransmittalCreatorService {
|
||||
private readonly logger = new Logger(TransmittalCreatorService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DistributionMatrix)
|
||||
private readonly matrixRepo: Repository<DistributionMatrix>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* สร้าง Transmittal draft จาก Distribution event (FR-019)
|
||||
* Note: actual Transmittal creation ผ่าน TransmittalModule — inject ที่ DI level
|
||||
*/
|
||||
async createFromDistribution(payload: {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
responseCode: string;
|
||||
}): Promise<{ transmittalPublicIds: string[] }> {
|
||||
const matrix = await this.matrixRepo.findOne({
|
||||
where: {
|
||||
projectId: payload.projectId,
|
||||
documentTypeCode: payload.documentTypeCode,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['recipients'],
|
||||
});
|
||||
|
||||
if (!matrix || !matrix.recipients || matrix.recipients.length === 0) {
|
||||
this.logger.log(
|
||||
`No distribution matrix found for project ${payload.projectId}, docType ${payload.documentTypeCode}`,
|
||||
);
|
||||
return { transmittalPublicIds: [] };
|
||||
}
|
||||
|
||||
// ตรวจสอบ response code filter
|
||||
if (
|
||||
matrix.responseCodeFilter &&
|
||||
matrix.responseCodeFilter.length > 0 &&
|
||||
!matrix.responseCodeFilter.includes(payload.responseCode)
|
||||
) {
|
||||
this.logger.log(
|
||||
`Response code ${payload.responseCode} not in filter — skipping distribution`,
|
||||
);
|
||||
return { transmittalPublicIds: [] };
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Creating Transmittal for RFA ${payload.rfaPublicId} → ${matrix.recipients.length} recipients`,
|
||||
);
|
||||
|
||||
// TODO: เรียก TransmittalService.create() เมื่อ integrate ใน Sprint ถัดไป
|
||||
// return transmittalService.createDraft({ rfaPublicId, recipients });
|
||||
|
||||
return { transmittalPublicIds: [] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// File: src/modules/reminder/dto/create-reminder-rule.dto.ts
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsArray, MaxLength } from 'class-validator';
|
||||
import { ReminderType } from '../../common/enums/review.enums';
|
||||
|
||||
export class CreateReminderRuleDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
projectId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
documentTypeCode?: string;
|
||||
|
||||
@IsEnum(ReminderType)
|
||||
reminderType!: ReminderType;
|
||||
|
||||
@IsInt()
|
||||
daysBeforeDue!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
escalationLevel?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
notifyRoles?: string[];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// File: src/modules/reminder/entities/reminder-rule.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { ReminderType } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('reminder_rules')
|
||||
export class ReminderRule extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'project_id', nullable: true })
|
||||
@Exclude()
|
||||
projectId?: number; // NULL = global rule
|
||||
|
||||
@Column({ name: 'document_type_code', length: 20, nullable: true })
|
||||
documentTypeCode?: string; // 'SDW', 'DDW' — NULL = all types
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ReminderType,
|
||||
})
|
||||
reminderType!: ReminderType;
|
||||
|
||||
@Column({ name: 'days_before_due', type: 'int' })
|
||||
daysBeforeDue!: number; // บวก = ก่อน due, ลบ = หลัง due (overdue)
|
||||
|
||||
@Column({ name: 'escalation_level', type: 'tinyint', default: 0 })
|
||||
escalationLevel!: number; // 0 = reminder, 1 = escalation L1, 2 = escalation L2
|
||||
|
||||
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
|
||||
notifyRoles?: string[]; // เช่น ['TASK_ASSIGNEE', 'TEAM_LEAD', 'PROJECT_MANAGER']
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// File: src/modules/reminder/processors/reminder.processor.ts
|
||||
// BullMQ Worker สำหรับประมวลผล Reminder jobs (ADR-008)
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
||||
import { ReminderType } from '../../common/enums/review.enums';
|
||||
import { EscalationService } from '../services/escalation.service';
|
||||
import { NotificationService } from '../../notification/notification.service';
|
||||
import { ScheduleReminderPayload } from '../services/scheduler.service';
|
||||
|
||||
@Processor(QUEUE_REMINDERS)
|
||||
export class ReminderProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(ReminderProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly escalationService: EscalationService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<ScheduleReminderPayload>): Promise<void> {
|
||||
const { taskPublicId, assigneeUserId, reminderType } = job.data;
|
||||
|
||||
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.',
|
||||
type: 'SYSTEM',
|
||||
entityType: 'review_task',
|
||||
entityId: taskPublicId as unknown as number,
|
||||
});
|
||||
break;
|
||||
|
||||
case ReminderType.ON_DUE:
|
||||
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.',
|
||||
type: 'SYSTEM',
|
||||
entityType: 'review_task',
|
||||
entityId: taskPublicId as unknown as number,
|
||||
});
|
||||
break;
|
||||
|
||||
case ReminderType.OVERDUE:
|
||||
await this.notificationService.send({
|
||||
userId: assigneeUserId,
|
||||
title: '🚨 Review Task Overdue',
|
||||
message: 'Your review task is overdue. Escalation will occur if not completed.',
|
||||
type: 'SYSTEM',
|
||||
entityType: 'review_task',
|
||||
entityId: taskPublicId as unknown as number,
|
||||
});
|
||||
break;
|
||||
|
||||
case ReminderType.ESCALATION_L1:
|
||||
await this.escalationService.escalateLevel1(taskPublicId);
|
||||
break;
|
||||
|
||||
case ReminderType.ESCALATION_L2:
|
||||
await this.escalationService.escalateLevel2(taskPublicId);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown reminder type: ${reminderType as string}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ReminderService } from './reminder.service';
|
||||
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
|
||||
|
||||
@Controller('admin/reminder-rules')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReminderController {
|
||||
constructor(private readonly reminderService: ReminderService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query('projectId') projectId?: string) {
|
||||
return this.reminderService.findAll(projectId ? parseInt(projectId, 10) : undefined);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
findOne(@Param('publicId') publicId: string) {
|
||||
return this.reminderService.findOne(publicId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateReminderRuleDto): Promise<unknown> {
|
||||
return this.reminderService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':publicId')
|
||||
update(@Param('publicId') publicId: string, @Body() dto: Partial<CreateReminderRuleDto>): Promise<unknown> {
|
||||
return this.reminderService.update(publicId, dto);
|
||||
}
|
||||
|
||||
@Delete(':publicId')
|
||||
remove(@Param('publicId') publicId: string) {
|
||||
return this.reminderService.remove(publicId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// File: src/modules/reminder/reminder.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ReminderRule } from './entities/reminder-rule.entity';
|
||||
import { ReviewTask } from '../review-team/entities/review-task.entity';
|
||||
import { ReminderService } from './reminder.service';
|
||||
import { ReminderController } from './reminder.controller';
|
||||
import { SchedulerService } from './services/scheduler.service';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ReminderRule, ReviewTask]),
|
||||
BullModule.registerQueue({ name: QUEUE_REMINDERS }),
|
||||
NotificationModule,
|
||||
],
|
||||
providers: [
|
||||
ReminderService,
|
||||
SchedulerService,
|
||||
EscalationService,
|
||||
ReminderProcessor,
|
||||
],
|
||||
controllers: [ReminderController],
|
||||
exports: [ReminderService, SchedulerService, EscalationService],
|
||||
})
|
||||
export class ReminderModule {}
|
||||
@@ -0,0 +1,51 @@
|
||||
// File: src/modules/reminder/reminder.service.ts
|
||||
// ReminderService — CRUD สำหรับ ReminderRule entities (T044)
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReminderRule } from './entities/reminder-rule.entity';
|
||||
import { CreateReminderRuleDto } from './dto/create-reminder-rule.dto';
|
||||
|
||||
export { CreateReminderRuleDto };
|
||||
|
||||
@Injectable()
|
||||
export class ReminderService {
|
||||
private readonly logger = new Logger(ReminderService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReminderRule)
|
||||
private readonly ruleRepo: Repository<ReminderRule>,
|
||||
) {}
|
||||
|
||||
async findAll(projectId?: number): Promise<ReminderRule[]> {
|
||||
if (projectId !== undefined) {
|
||||
return this.ruleRepo.find({
|
||||
where: [{ projectId }, { projectId: undefined }],
|
||||
order: { escalationLevel: 'ASC', daysBeforeDue: 'DESC' },
|
||||
});
|
||||
}
|
||||
return this.ruleRepo.find({ order: { escalationLevel: 'ASC' } });
|
||||
}
|
||||
|
||||
async findOne(publicId: string): Promise<ReminderRule> {
|
||||
const rule = await this.ruleRepo.findOne({ where: { publicId } });
|
||||
if (!rule) throw new NotFoundException(`ReminderRule not found: ${publicId}`);
|
||||
return rule;
|
||||
}
|
||||
|
||||
async create(dto: CreateReminderRuleDto): Promise<ReminderRule> {
|
||||
const rule = this.ruleRepo.create(dto as Partial<ReminderRule>);
|
||||
return this.ruleRepo.save(rule);
|
||||
}
|
||||
|
||||
async update(publicId: string, dto: Partial<CreateReminderRuleDto>): Promise<ReminderRule> {
|
||||
const rule = await this.findOne(publicId);
|
||||
Object.assign(rule, dto);
|
||||
return this.ruleRepo.save(rule);
|
||||
}
|
||||
|
||||
async remove(publicId: string): Promise<void> {
|
||||
const rule = await this.findOne(publicId);
|
||||
await this.ruleRepo.remove(rule);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// File: src/modules/reminder/services/escalation.service.ts
|
||||
// 2-Level Escalation เมื่อ Review Task เกิน due date (FR-015)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { ReviewTask } from '../../review-team/entities/review-task.entity';
|
||||
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
import { NotificationService } from '../../notification/notification.service';
|
||||
import { ReminderRule } from '../entities/reminder-rule.entity';
|
||||
|
||||
@Injectable()
|
||||
export class EscalationService {
|
||||
private readonly logger = new Logger(EscalationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
@InjectRepository(ReminderRule)
|
||||
private readonly reminderRuleRepo: Repository<ReminderRule>,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Escalation Level 1 (FR-015): Team Lead ได้รับแจ้งเตือน
|
||||
* เรียกเมื่อ task เกิน due date 1 วัน
|
||||
*/
|
||||
async escalateLevel1(taskPublicId: string): Promise<void> {
|
||||
const task = await this.reviewTaskRepo.findOne({
|
||||
where: { publicId: taskPublicId },
|
||||
relations: ['team', 'assignedToUser', 'discipline'],
|
||||
});
|
||||
|
||||
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
||||
|
||||
const daysOverdue = task.dueDate
|
||||
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
||||
: 0;
|
||||
|
||||
if (daysOverdue < 1) return;
|
||||
|
||||
this.logger.log(
|
||||
`Escalation L1: task ${taskPublicId} is ${daysOverdue} days overdue`,
|
||||
);
|
||||
|
||||
// แจ้ง Team Lead
|
||||
if (task.assignedToUserId) {
|
||||
await this.notificationService.send({
|
||||
userId: task.assignedToUserId,
|
||||
title: `⚠ Review Task Overdue (${daysOverdue}d)`,
|
||||
message: `Your review task is overdue by ${daysOverdue} day(s). Please complete it immediately.`,
|
||||
type: 'SYSTEM',
|
||||
entityType: 'review_task',
|
||||
entityId: task.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escalation Level 2 (FR-016): Project Manager ได้รับแจ้งเตือน
|
||||
* เรียกเมื่อ task เกิน due date 3 วัน
|
||||
*/
|
||||
async escalateLevel2(taskPublicId: string): Promise<void> {
|
||||
const task = await this.reviewTaskRepo.findOne({
|
||||
where: { publicId: taskPublicId },
|
||||
relations: ['team', 'assignedToUser'],
|
||||
});
|
||||
|
||||
if (!task || task.status === ReviewTaskStatus.COMPLETED) return;
|
||||
|
||||
const daysOverdue = task.dueDate
|
||||
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
||||
: 0;
|
||||
|
||||
if (daysOverdue < 3) return;
|
||||
|
||||
this.logger.warn(
|
||||
`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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* สแกน tasks ที่ overdue ทั้งหมด และ escalate ตาม level (cron trigger)
|
||||
*/
|
||||
async processOverdueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const overdueTasks = await this.reviewTaskRepo.find({
|
||||
where: {
|
||||
status: ReviewTaskStatus.IN_PROGRESS,
|
||||
dueDate: LessThan(now),
|
||||
},
|
||||
select: ['publicId', 'dueDate'],
|
||||
});
|
||||
|
||||
this.logger.log(`Processing ${overdueTasks.length} overdue tasks`);
|
||||
|
||||
for (const task of overdueTasks) {
|
||||
const daysOverdue = task.dueDate
|
||||
? Math.floor((Date.now() - task.dueDate.getTime()) / 86_400_000)
|
||||
: 0;
|
||||
|
||||
if (daysOverdue >= 3) {
|
||||
await this.escalateLevel2(task.publicId);
|
||||
} else if (daysOverdue >= 1) {
|
||||
await this.escalateLevel1(task.publicId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// File: src/modules/reminder/services/scheduler.service.ts
|
||||
// Schedule reminders เมื่อ RFA submit (FR-013) — เพิ่ม jobs เข้า BullMQ queue
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QUEUE_REMINDERS } from '../../common/constants/queue.constants';
|
||||
import type { Job } from 'bullmq';
|
||||
import { ReminderType } from '../../common/enums/review.enums';
|
||||
|
||||
export interface ScheduleReminderPayload {
|
||||
taskPublicId: string;
|
||||
rfaPublicId: string;
|
||||
assigneeUserId: number;
|
||||
dueDate: Date;
|
||||
reminderType: ReminderType;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SchedulerService {
|
||||
private readonly logger = new Logger(SchedulerService.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(QUEUE_REMINDERS)
|
||||
private readonly reminderQueue: Queue,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Schedule ชุด reminders ให้ Review Task (FR-013)
|
||||
* เรียกหลังจาก TaskCreationService สร้าง tasks เรียบร้อยแล้ว
|
||||
*/
|
||||
async scheduleForTask(payload: ScheduleReminderPayload): Promise<void> {
|
||||
const { taskPublicId, dueDate } = payload;
|
||||
const now = Date.now();
|
||||
|
||||
const remindersToSchedule: Array<{ type: ReminderType; delayMs: number }> = [];
|
||||
|
||||
// 2 วันก่อน due date
|
||||
const twoDaysBefore = dueDate.getTime() - 2 * 86_400_000;
|
||||
if (twoDaysBefore > now) {
|
||||
remindersToSchedule.push({
|
||||
type: ReminderType.DUE_SOON,
|
||||
delayMs: twoDaysBefore - now,
|
||||
});
|
||||
}
|
||||
|
||||
// วัน due date เอง
|
||||
const onDue = dueDate.getTime();
|
||||
if (onDue > now) {
|
||||
remindersToSchedule.push({
|
||||
type: ReminderType.ON_DUE,
|
||||
delayMs: onDue - now,
|
||||
});
|
||||
}
|
||||
|
||||
// 1 วันหลัง due (Escalation L1)
|
||||
const oneDayAfter = dueDate.getTime() + 1 * 86_400_000;
|
||||
remindersToSchedule.push({
|
||||
type: ReminderType.ESCALATION_L1,
|
||||
delayMs: Math.max(oneDayAfter - now, 0),
|
||||
});
|
||||
|
||||
// 3 วันหลัง due (Escalation L2)
|
||||
const threeDaysAfter = dueDate.getTime() + 3 * 86_400_000;
|
||||
remindersToSchedule.push({
|
||||
type: ReminderType.ESCALATION_L2,
|
||||
delayMs: Math.max(threeDaysAfter - now, 0),
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
remindersToSchedule.map(({ type, delayMs }) =>
|
||||
this.reminderQueue.add(
|
||||
'send-reminder',
|
||||
{ ...payload, reminderType: type },
|
||||
{ delay: delayMs, removeOnComplete: true, removeOnFail: 100 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Scheduled ${remindersToSchedule.length} reminders for task ${taskPublicId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ยกเลิก reminders ทั้งหมดของ task (เมื่อ task complete หรือ cancelled)
|
||||
*/
|
||||
async cancelForTask(taskPublicId: string): Promise<void> {
|
||||
const jobs = await this.reminderQueue.getDelayed();
|
||||
const taskJobs = jobs.filter((j: Job) => j.data?.taskPublicId === taskPublicId);
|
||||
|
||||
await Promise.all(taskJobs.map((j: Job) => j.remove()));
|
||||
|
||||
this.logger.log(
|
||||
`Cancelled ${taskJobs.length} reminder jobs for task ${taskPublicId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// File: src/modules/response-code/entities/response-code-rule.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { ResponseCode } from './response-code.entity';
|
||||
|
||||
@Entity('response_code_rules')
|
||||
export class ResponseCodeRule extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'project_id', nullable: true })
|
||||
@Exclude()
|
||||
projectId?: number; // NULL = global default
|
||||
|
||||
@Column({ name: 'document_type_id' })
|
||||
@Exclude()
|
||||
documentTypeId!: number;
|
||||
|
||||
@Column({ name: 'response_code_id' })
|
||||
@Exclude()
|
||||
responseCodeId!: number;
|
||||
|
||||
@Column({ name: 'is_enabled', type: 'tinyint', default: 1 })
|
||||
isEnabled!: boolean;
|
||||
|
||||
@Column({ name: 'requires_comments', type: 'tinyint', default: 0 })
|
||||
requiresComments!: boolean;
|
||||
|
||||
@Column({ name: 'triggers_notification', type: 'tinyint', default: 0 })
|
||||
triggersNotification!: boolean;
|
||||
|
||||
@Column({ name: 'parent_rule_id', nullable: true })
|
||||
@Exclude()
|
||||
parentRuleId?: number; // Inheritance tracking
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ResponseCode, (code: ResponseCode) => code.rules)
|
||||
@JoinColumn({ name: 'response_code_id' })
|
||||
responseCode!: ResponseCode;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// File: src/modules/response-code/entities/response-code.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||
import { ResponseCodeRule } from './response-code-rule.entity';
|
||||
|
||||
@Entity('response_codes')
|
||||
export class ResponseCode extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ length: 10 })
|
||||
code!: string; // '1A', '1B', '1C', ..., '2', '3', '4'
|
||||
|
||||
@Column({ name: 'sub_status', length: 10, nullable: true })
|
||||
subStatus?: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ResponseCodeCategory,
|
||||
})
|
||||
category!: ResponseCodeCategory;
|
||||
|
||||
@Column({ name: 'description_th', type: 'text' })
|
||||
descriptionTh!: string;
|
||||
|
||||
@Column({ name: 'description_en', type: 'text' })
|
||||
descriptionEn!: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
implications?: {
|
||||
affectsSchedule?: boolean;
|
||||
affectsCost?: boolean;
|
||||
requiresContractReview?: boolean;
|
||||
requiresEiaAmendment?: boolean;
|
||||
};
|
||||
|
||||
@Column({ name: 'notify_roles', type: 'simple-array', nullable: true })
|
||||
notifyRoles?: string[]; // ['CONTRACT_MANAGER', 'QS_MANAGER']
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'is_system', type: 'tinyint', default: 0 })
|
||||
isSystem!: boolean; // System default — ลบไม่ได้
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => ResponseCodeRule, (rule: ResponseCodeRule) => rule.responseCode)
|
||||
rules?: ResponseCodeRule[];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// File: src/modules/response-code/response-code.controller.ts
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ResponseCodeService } from './response-code.service';
|
||||
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
||||
|
||||
@Controller('response-codes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ResponseCodeController {
|
||||
constructor(private readonly responseCodeService: ResponseCodeService) {}
|
||||
|
||||
/**
|
||||
* GET /response-codes
|
||||
* ดึง Response Codes ทั้งหมด
|
||||
*/
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.responseCodeService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /response-codes/category/:category
|
||||
* ดึง Response Codes ตาม Category (FR-006)
|
||||
*/
|
||||
@Get('category/:category')
|
||||
findByCategory(@Param('category') category: ResponseCodeCategory) {
|
||||
return this.responseCodeService.findByCategory(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /response-codes/document-type/:id
|
||||
* ดึง Response Codes ที่ใช้ได้กับ document type + project
|
||||
*/
|
||||
@Get('document-type/:documentTypeId')
|
||||
findByDocumentType(
|
||||
@Param('documentTypeId') documentTypeId: string,
|
||||
@Query('projectId') projectId?: string,
|
||||
) {
|
||||
return this.responseCodeService.findByDocumentType(
|
||||
Number(documentTypeId),
|
||||
projectId ? Number(projectId) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /response-codes/:publicId
|
||||
* ดึง Response Code ตาม publicId (ADR-019)
|
||||
*/
|
||||
@Get(':publicId')
|
||||
findOne(@Param('publicId') publicId: string) {
|
||||
return this.responseCodeService.findByPublicId(publicId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// File: src/modules/response-code/response-code.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ResponseCode } from './entities/response-code.entity';
|
||||
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
||||
import { ResponseCodeService } from './response-code.service';
|
||||
import { ResponseCodeController } from './response-code.controller';
|
||||
import { ImplicationsService } from './services/implications.service';
|
||||
import { NotificationTriggerService } from './services/notification-trigger.service';
|
||||
import { MatrixManagementService } from './services/matrix-management.service';
|
||||
import { InheritanceService } from './services/inheritance.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ResponseCode, ResponseCodeRule, User]),
|
||||
NotificationModule,
|
||||
],
|
||||
providers: [
|
||||
ResponseCodeService,
|
||||
ImplicationsService,
|
||||
NotificationTriggerService,
|
||||
MatrixManagementService,
|
||||
InheritanceService,
|
||||
],
|
||||
controllers: [ResponseCodeController],
|
||||
exports: [
|
||||
ResponseCodeService,
|
||||
ImplicationsService,
|
||||
NotificationTriggerService,
|
||||
MatrixManagementService,
|
||||
InheritanceService,
|
||||
],
|
||||
})
|
||||
export class ResponseCodeModule {}
|
||||
@@ -0,0 +1,94 @@
|
||||
// File: src/modules/response-code/response-code.service.ts
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { ResponseCode } from './entities/response-code.entity';
|
||||
import { ResponseCodeRule } from './entities/response-code-rule.entity';
|
||||
import { ResponseCodeCategory } from '../common/enums/review.enums';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseCodeService {
|
||||
private readonly logger = new Logger(ResponseCodeService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||
@InjectRepository(ResponseCodeRule)
|
||||
private readonly responseCodeRuleRepo: Repository<ResponseCodeRule>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึง Response Codes ทั้งหมดที่ active
|
||||
*/
|
||||
async findAll(): Promise<ResponseCode[]> {
|
||||
return this.responseCodeRepo.find({
|
||||
where: { isActive: true },
|
||||
order: { category: 'ASC', code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Response Codes ตาม Category (FR-006)
|
||||
* ใช้สำหรับแสดงผลใน Review page ตามประเภทเอกสาร
|
||||
*/
|
||||
async findByCategory(category: ResponseCodeCategory): Promise<ResponseCode[]> {
|
||||
return this.responseCodeRepo.find({
|
||||
where: { category, isActive: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Response Codes ที่ใช้ได้กับ document type + project
|
||||
* รองรับ Global default + Project override (ADR-019 Q1 clarification)
|
||||
*/
|
||||
async findByDocumentType(
|
||||
documentTypeId: number,
|
||||
projectId?: number,
|
||||
): Promise<ResponseCode[]> {
|
||||
// ดึง Rules ระดับ Project (ถ้ามี) หรือ Global default
|
||||
const rules = await this.responseCodeRuleRepo.find({
|
||||
where: [
|
||||
{ documentTypeId, projectId: projectId ?? IsNull(), isEnabled: true },
|
||||
{ documentTypeId, projectId: IsNull(), isEnabled: true },
|
||||
],
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
// Project rules override global rules
|
||||
const codeMap = new Map<number, ResponseCode>();
|
||||
for (const rule of rules) {
|
||||
if (rule.responseCode?.isActive) {
|
||||
codeMap.set(rule.responseCodeId, rule.responseCode);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(codeMap.values()).sort((a, b) =>
|
||||
a.code.localeCompare(b.code),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง ResponseCode โดย publicId (ADR-019)
|
||||
*/
|
||||
async findByPublicId(publicId: string): Promise<ResponseCode> {
|
||||
const code = await this.responseCodeRepo.findOne({
|
||||
where: { publicId },
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
throw new NotFoundException(`Response Code not found: ${publicId}`);
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า Response Code triggers notification หรือไม่ (FR-007)
|
||||
* Code 1C, 1D, 3 → trigger notification
|
||||
*/
|
||||
async getNotifyRoles(responseCodePublicId: string): Promise<string[]> {
|
||||
const code = await this.findByPublicId(responseCodePublicId);
|
||||
return code.notifyRoles ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// File: src/modules/response-code/seeders/response-code.seed.ts
|
||||
// Seed data สำหรับ Master Approval Matrix — Response Codes มาตรฐาน
|
||||
// อ้างอิง: specs/1-rfa-approval-refactor/spec.md — Comprehensive Master Approval Matrix
|
||||
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ResponseCode } from '../entities/response-code.entity';
|
||||
import { ResponseCodeCategory } from '../../common/enums/review.enums';
|
||||
|
||||
export const responseCodeSeedData = [
|
||||
// ─── ENGINEERING Category (Shop Drawing, Method Statement, As-Built) ───────
|
||||
{
|
||||
code: '1A',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติเพื่อก่อสร้าง',
|
||||
descriptionEn: 'Approved for Construction',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1B',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติเพื่อก่อสร้าง พร้อมความเห็น (แก้ไขไม่ต้องส่งกลับ)',
|
||||
descriptionEn: 'Approved for Construction with Comments (No Resubmission Required)',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1C',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติ — มีผลต่อสัญญา/Change Order',
|
||||
descriptionEn: 'Approved — Contract Implications / Change Order Required',
|
||||
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
|
||||
notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1D',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติทางเลือก — แตกต่างจากแบบสัญญา',
|
||||
descriptionEn: 'Approved Alternative — Differs from Contract Drawing',
|
||||
implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true },
|
||||
notifyRoles: ['CONTRACT_MANAGER', 'DESIGN_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1E',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติเพื่อวัตถุประสงค์การออกแบบเท่านั้น',
|
||||
descriptionEn: 'Approved for Design Purpose Only',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1F',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติเพื่ออ้างอิงเท่านั้น',
|
||||
descriptionEn: 'Approved for Reference Only',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1G',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติพร้อมเงื่อนไข ESG',
|
||||
descriptionEn: 'Approved with ESG Conditions',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true },
|
||||
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '2',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'อนุมัติตามหมายเหตุ — ต้องแก้ไขและส่งกลับเพื่อตรวจสอบ',
|
||||
descriptionEn: 'Approved as Noted — Revise and Resubmit for Review',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '3',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'ปฏิเสธ — ต้องแก้ไขและส่งใหม่',
|
||||
descriptionEn: 'Rejected — Revise and Resubmit',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: ['PROJECT_MANAGER', 'DESIGN_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '4',
|
||||
category: ResponseCodeCategory.ENGINEERING,
|
||||
descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน',
|
||||
descriptionEn: 'Not Applicable / Withdrawn',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
|
||||
// ─── MATERIAL Category ────────────────────────────────────────────────────
|
||||
{
|
||||
code: '1A',
|
||||
category: ResponseCodeCategory.MATERIAL,
|
||||
descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์เพื่อจัดซื้อ',
|
||||
descriptionEn: 'Approved for Procurement',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1B',
|
||||
category: ResponseCodeCategory.MATERIAL,
|
||||
descriptionTh: 'อนุมัติวัสดุ/อุปกรณ์ พร้อมความเห็น',
|
||||
descriptionEn: 'Approved for Procurement with Comments',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1C',
|
||||
category: ResponseCodeCategory.MATERIAL,
|
||||
descriptionTh: 'อนุมัติ — มีผลต่อค่าใช้จ่าย',
|
||||
descriptionEn: 'Approved — Cost Implications',
|
||||
implications: { affectsSchedule: false, affectsCost: true, requiresContractReview: true },
|
||||
notifyRoles: ['QS_MANAGER', 'PROCUREMENT_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '2',
|
||||
category: ResponseCodeCategory.MATERIAL,
|
||||
descriptionTh: 'ส่งข้อมูลเพิ่มเติม',
|
||||
descriptionEn: 'Provide Additional Information',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '3',
|
||||
category: ResponseCodeCategory.MATERIAL,
|
||||
descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามสัญญา',
|
||||
descriptionEn: 'Rejected — Non-Compliant with Contract',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: ['PROJECT_MANAGER', 'PROCUREMENT_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '4',
|
||||
category: ResponseCodeCategory.MATERIAL,
|
||||
descriptionTh: 'ไม่เกี่ยวข้อง / ถอนคืน',
|
||||
descriptionEn: 'Not Applicable / Withdrawn',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
|
||||
// ─── CONTRACT Category ────────────────────────────────────────────────────
|
||||
{
|
||||
code: '1A',
|
||||
category: ResponseCodeCategory.CONTRACT,
|
||||
descriptionTh: 'อนุมัติ — ไม่มีผลต่อสัญญา',
|
||||
descriptionEn: 'Approved — No Contract Implication',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1C',
|
||||
category: ResponseCodeCategory.CONTRACT,
|
||||
descriptionTh: 'อนุมัติ — ต้องออก Change Order',
|
||||
descriptionEn: 'Approved — Change Order Required',
|
||||
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
|
||||
notifyRoles: ['CONTRACT_MANAGER', 'QS_MANAGER', 'PROJECT_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '2',
|
||||
category: ResponseCodeCategory.CONTRACT,
|
||||
descriptionTh: 'อยู่ระหว่างการพิจารณา — ต้องการข้อมูลเพิ่มเติม',
|
||||
descriptionEn: 'Under Review — Additional Information Required',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '3',
|
||||
category: ResponseCodeCategory.CONTRACT,
|
||||
descriptionTh: 'ปฏิเสธ — ขัดต่อเงื่อนไขสัญญา',
|
||||
descriptionEn: 'Rejected — Contradicts Contract Terms',
|
||||
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: true },
|
||||
notifyRoles: ['CONTRACT_MANAGER', 'LEGAL_TEAM', 'PROJECT_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
|
||||
// ─── TESTING Category ─────────────────────────────────────────────────────
|
||||
{
|
||||
code: '1A',
|
||||
category: ResponseCodeCategory.TESTING,
|
||||
descriptionTh: 'อนุมัติผลการทดสอบ / ส่งมอบ',
|
||||
descriptionEn: 'Approved — Test Results / Handover Accepted',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '2',
|
||||
category: ResponseCodeCategory.TESTING,
|
||||
descriptionTh: 'ผ่านพร้อมข้อบกพร่องเล็กน้อย — ต้องแก้ไขและรายงาน',
|
||||
descriptionEn: 'Passed with Minor Defects — Rectify and Report',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: ['QA_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '3',
|
||||
category: ResponseCodeCategory.TESTING,
|
||||
descriptionTh: 'ไม่ผ่าน — ต้องทดสอบซ้ำ',
|
||||
descriptionEn: 'Failed — Retest Required',
|
||||
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false },
|
||||
notifyRoles: ['PROJECT_MANAGER', 'QA_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
|
||||
// ─── ESG Category ──────────────────────────────────────────────────────────
|
||||
{
|
||||
code: '1A',
|
||||
category: ResponseCodeCategory.ESG,
|
||||
descriptionTh: 'อนุมัติ — เป็นไปตามมาตรฐาน ESG',
|
||||
descriptionEn: 'Approved — ESG Compliant',
|
||||
implications: { affectsSchedule: false, affectsCost: false, requiresContractReview: false },
|
||||
notifyRoles: [],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '1G',
|
||||
category: ResponseCodeCategory.ESG,
|
||||
descriptionTh: 'อนุมัติพร้อมเงื่อนไขด้านสิ่งแวดล้อม',
|
||||
descriptionEn: 'Approved with Environmental Conditions',
|
||||
implications: { affectsSchedule: true, affectsCost: false, requiresContractReview: false, requiresEiaAmendment: true },
|
||||
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
code: '3',
|
||||
category: ResponseCodeCategory.ESG,
|
||||
descriptionTh: 'ปฏิเสธ — ไม่เป็นไปตามข้อกำหนด EIA/ESG',
|
||||
descriptionEn: 'Rejected — Non-Compliant with EIA/ESG Requirements',
|
||||
implications: { affectsSchedule: true, affectsCost: true, requiresContractReview: false, requiresEiaAmendment: true },
|
||||
notifyRoles: ['EIA_OFFICER', 'HSE_MANAGER', 'PROJECT_MANAGER'],
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Seed Response Codes ลงฐานข้อมูล
|
||||
* ใช้สำหรับ initial setup และ test environments
|
||||
*/
|
||||
export async function seedResponseCodes(dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(ResponseCode);
|
||||
|
||||
for (const data of responseCodeSeedData) {
|
||||
const exists = await repo.findOne({
|
||||
where: { code: data.code, category: data.category as ResponseCodeCategory },
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
const entity = repo.create({
|
||||
code: data.code,
|
||||
category: data.category as ResponseCodeCategory,
|
||||
descriptionTh: data.descriptionTh,
|
||||
descriptionEn: data.descriptionEn,
|
||||
implications: data.implications,
|
||||
notifyRoles: data.notifyRoles,
|
||||
isSystem: data.isSystem,
|
||||
isActive: true,
|
||||
});
|
||||
await repo.save(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// File: src/modules/response-code/services/implications.service.ts
|
||||
// ประเมินผลกระทบของ Response Code ที่เลือก (FR-007)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ResponseCode } from '../entities/response-code.entity';
|
||||
|
||||
export interface CodeImplicationResult {
|
||||
affectsSchedule: boolean;
|
||||
affectsCost: boolean;
|
||||
requiresContractReview: boolean;
|
||||
requiresEiaAmendment: boolean;
|
||||
notifyRoles: string[];
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
actionRequired: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImplicationsService {
|
||||
private readonly logger = new Logger(ImplicationsService.name);
|
||||
|
||||
/**
|
||||
* ประเมินผลกระทบของ Response Code (FR-007)
|
||||
* Code 1C, 1D, 3 → Critical → trigger notifications
|
||||
*/
|
||||
evaluate(responseCode: ResponseCode): CodeImplicationResult {
|
||||
const implications = responseCode.implications ?? {};
|
||||
const notifyRoles = responseCode.notifyRoles ?? [];
|
||||
|
||||
const affectsSchedule = implications.affectsSchedule ?? false;
|
||||
const affectsCost = implications.affectsCost ?? false;
|
||||
const requiresContractReview = implications.requiresContractReview ?? false;
|
||||
const requiresEiaAmendment = implications.requiresEiaAmendment ?? false;
|
||||
|
||||
// กำหนด severity ตามน้ำหนักผลกระทบ
|
||||
const severity = this.calculateSeverity(
|
||||
responseCode.code,
|
||||
affectsSchedule,
|
||||
affectsCost,
|
||||
requiresContractReview,
|
||||
);
|
||||
|
||||
const actionRequired = this.buildActionList(
|
||||
responseCode.code,
|
||||
requiresContractReview,
|
||||
requiresEiaAmendment,
|
||||
affectsCost,
|
||||
);
|
||||
|
||||
return {
|
||||
affectsSchedule,
|
||||
affectsCost,
|
||||
requiresContractReview,
|
||||
requiresEiaAmendment,
|
||||
notifyRoles,
|
||||
severity,
|
||||
actionRequired,
|
||||
};
|
||||
}
|
||||
|
||||
private calculateSeverity(
|
||||
code: string,
|
||||
affectsSchedule: boolean,
|
||||
affectsCost: boolean,
|
||||
requiresContractReview: boolean,
|
||||
): CodeImplicationResult['severity'] {
|
||||
// Code 3 (Rejected) = CRITICAL เสมอ
|
||||
if (code === '3') return 'CRITICAL';
|
||||
|
||||
// Code 1C (Contract Implications) หรือ 1D (Alternative) = HIGH
|
||||
if (code === '1C' || code === '1D') return 'HIGH';
|
||||
|
||||
// มีผลต่อทั้ง schedule และ cost
|
||||
if (affectsSchedule && affectsCost) return 'HIGH';
|
||||
|
||||
// มีผลต่ออย่างใดอย่างหนึ่ง
|
||||
if (requiresContractReview || affectsSchedule || affectsCost) return 'MEDIUM';
|
||||
|
||||
return 'LOW';
|
||||
}
|
||||
|
||||
private buildActionList(
|
||||
code: string,
|
||||
requiresContractReview: boolean,
|
||||
requiresEiaAmendment: boolean,
|
||||
affectsCost: boolean,
|
||||
): string[] {
|
||||
const actions: string[] = [];
|
||||
|
||||
if (code === '3') {
|
||||
actions.push('Document rejected — originator must revise and resubmit');
|
||||
}
|
||||
|
||||
if (requiresContractReview) {
|
||||
actions.push('Contract review required — notify Contract Manager');
|
||||
}
|
||||
|
||||
if (affectsCost) {
|
||||
actions.push('Cost impact assessment required — notify QS Manager');
|
||||
}
|
||||
|
||||
if (requiresEiaAmendment) {
|
||||
actions.push('EIA amendment may be required — notify EIA Officer');
|
||||
}
|
||||
|
||||
if (code === '2') {
|
||||
actions.push('Minor comments — originator to revise and resubmit');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// File: src/modules/response-code/services/inheritance.service.ts
|
||||
// Resolves project-level overrides inheriting from global defaults (T062, FR-021)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ResponseCodeRule } from '../entities/response-code-rule.entity';
|
||||
|
||||
export interface ResolvedMatrix {
|
||||
responseCodeId: number;
|
||||
responseCodePublicId: string;
|
||||
documentTypeId: number;
|
||||
isEnabled: boolean;
|
||||
requiresComments: boolean;
|
||||
triggersNotification: boolean;
|
||||
isOverridden: boolean; // true = project-specific rule overrides global
|
||||
parentRuleId?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InheritanceService {
|
||||
private readonly logger = new Logger(InheritanceService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ResponseCodeRule)
|
||||
private readonly ruleRepo: Repository<ResponseCodeRule>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึง rules สำหรับ document type โดย merge global + project overrides (FR-021)
|
||||
* Project rule ชนะ global rule ของ responseCode เดียวกัน
|
||||
*
|
||||
* @param documentTypeId - document type ที่ต้องการ
|
||||
* @param projectId - project ID (NULL = global only)
|
||||
*/
|
||||
async resolveMatrix(
|
||||
documentTypeId: number,
|
||||
projectId?: number,
|
||||
): Promise<ResolvedMatrix[]> {
|
||||
// ดึง global rules (projectId IS NULL)
|
||||
const globalRules = await this.ruleRepo.find({
|
||||
where: { documentTypeId, projectId: undefined },
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
if (!projectId) {
|
||||
return globalRules.map((r) => ({
|
||||
responseCodeId: r.responseCodeId,
|
||||
responseCodePublicId: r.responseCode.publicId,
|
||||
documentTypeId: r.documentTypeId,
|
||||
isEnabled: r.isEnabled,
|
||||
requiresComments: r.requiresComments,
|
||||
triggersNotification: r.triggersNotification,
|
||||
isOverridden: false,
|
||||
parentRuleId: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// ดึง project-specific overrides
|
||||
const projectRules = await this.ruleRepo.find({
|
||||
where: { documentTypeId, projectId },
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
// Build map: responseCodeId → project rule
|
||||
const projectRuleMap = new Map(
|
||||
projectRules.map((r) => [r.responseCodeId, r]),
|
||||
);
|
||||
|
||||
// Merge: project overrides global
|
||||
const merged: ResolvedMatrix[] = globalRules.map((global) => {
|
||||
const override = projectRuleMap.get(global.responseCodeId);
|
||||
if (override) {
|
||||
return {
|
||||
responseCodeId: override.responseCodeId,
|
||||
responseCodePublicId: override.responseCode.publicId,
|
||||
documentTypeId: override.documentTypeId,
|
||||
isEnabled: override.isEnabled,
|
||||
requiresComments: override.requiresComments,
|
||||
triggersNotification: override.triggersNotification,
|
||||
isOverridden: true,
|
||||
parentRuleId: global.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
responseCodeId: global.responseCodeId,
|
||||
responseCodePublicId: global.responseCode.publicId,
|
||||
documentTypeId: global.documentTypeId,
|
||||
isEnabled: global.isEnabled,
|
||||
requiresComments: global.requiresComments,
|
||||
triggersNotification: global.triggersNotification,
|
||||
isOverridden: false,
|
||||
parentRuleId: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// เพิ่ม project-only rules (ไม่มี global parent)
|
||||
for (const projectRule of projectRules) {
|
||||
const alreadyMerged = globalRules.some(
|
||||
(g) => g.responseCodeId === projectRule.responseCodeId,
|
||||
);
|
||||
if (!alreadyMerged) {
|
||||
merged.push({
|
||||
responseCodeId: projectRule.responseCodeId,
|
||||
responseCodePublicId: projectRule.responseCode.publicId,
|
||||
documentTypeId: projectRule.documentTypeId,
|
||||
isEnabled: projectRule.isEnabled,
|
||||
requiresComments: projectRule.requiresComments,
|
||||
triggersNotification: projectRule.triggersNotification,
|
||||
isOverridden: true,
|
||||
parentRuleId: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Resolved ${merged.length} rules for docType=${documentTypeId}, project=${projectId}`,
|
||||
);
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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 { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ResponseCodeRule } from '../entities/response-code-rule.entity';
|
||||
import { ResponseCode } from '../entities/response-code.entity';
|
||||
|
||||
export interface UpsertRuleDto {
|
||||
documentTypeId: number;
|
||||
responseCodePublicId: string;
|
||||
projectId?: number;
|
||||
isEnabled: boolean;
|
||||
requiresComments?: boolean;
|
||||
triggersNotification?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MatrixManagementService {
|
||||
private readonly logger = new Logger(MatrixManagementService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ResponseCodeRule)
|
||||
private readonly ruleRepo: Repository<ResponseCodeRule>,
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly codeRepo: Repository<ResponseCode>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Upsert a rule — สร้างใหม่หรือแก้ไข existing rule (FR-022)
|
||||
*/
|
||||
async upsertRule(dto: UpsertRuleDto): Promise<ResponseCodeRule> {
|
||||
const code = await this.codeRepo.findOne({
|
||||
where: { publicId: dto.responseCodePublicId },
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
throw new NotFoundException(`ResponseCode not found: ${dto.responseCodePublicId}`);
|
||||
}
|
||||
|
||||
if (code.isSystem && !dto.isEnabled) {
|
||||
throw new BadRequestException('Cannot disable a system response code');
|
||||
}
|
||||
|
||||
const existing = await this.ruleRepo.findOne({
|
||||
where: {
|
||||
documentTypeId: dto.documentTypeId,
|
||||
responseCodeId: code.id,
|
||||
projectId: dto.projectId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
existing.isEnabled = dto.isEnabled;
|
||||
existing.requiresComments = dto.requiresComments ?? existing.requiresComments;
|
||||
existing.triggersNotification = dto.triggersNotification ?? existing.triggersNotification;
|
||||
return this.ruleRepo.save(existing);
|
||||
}
|
||||
|
||||
const rule = this.ruleRepo.create({
|
||||
documentTypeId: dto.documentTypeId,
|
||||
responseCodeId: code.id,
|
||||
projectId: dto.projectId,
|
||||
isEnabled: dto.isEnabled,
|
||||
requiresComments: dto.requiresComments ?? false,
|
||||
triggersNotification: dto.triggersNotification ?? false,
|
||||
} as Partial<ResponseCodeRule>);
|
||||
|
||||
return this.ruleRepo.save(rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง rules ทั้งหมดของ document type (global + project)
|
||||
*/
|
||||
async getRulesByDocType(
|
||||
documentTypeId: number,
|
||||
projectId?: number,
|
||||
): Promise<ResponseCodeRule[]> {
|
||||
const where: Record<string, unknown> = { documentTypeId };
|
||||
if (projectId !== undefined) {
|
||||
where['projectId'] = projectId;
|
||||
} else {
|
||||
where['projectId'] = undefined; // global only
|
||||
}
|
||||
|
||||
return this.ruleRepo.find({
|
||||
where,
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบ project override (หวนกลับใช้ global default)
|
||||
*/
|
||||
async deleteProjectOverride(rulePublicId: string): Promise<void> {
|
||||
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');
|
||||
}
|
||||
await this.ruleRepo.remove(rule);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// File: src/modules/response-code/services/notification-trigger.service.ts
|
||||
// ส่งการแจ้งเตือนอัตโนมัติเมื่อ Response Code มีผลกระทบสำคัญ (FR-007)
|
||||
// Code 1C (Change Order), 1D (Alternative), 3 (Rejected) → notify ฝ่ายที่เกี่ยวข้อง
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ResponseCode } from '../entities/response-code.entity';
|
||||
import { NotificationService } from '../../notification/notification.service';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { ImplicationsService } from './implications.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationTriggerService {
|
||||
private readonly logger = new Logger(NotificationTriggerService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly implicationsService: ImplicationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Trigger notifications เมื่อ Review Task เสร็จสิ้นด้วย Code ที่มีผลกระทบ (FR-007)
|
||||
* เรียกจาก ReviewTaskService หลังจาก completeReview
|
||||
*/
|
||||
async triggerIfRequired(
|
||||
responseCodePublicId: string,
|
||||
rfaPublicId: string,
|
||||
documentNumber: string,
|
||||
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}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const evaluation = this.implicationsService.evaluate(responseCode);
|
||||
|
||||
// ถ้า severity ต่ำ ไม่ต้อง notify เพิ่ม
|
||||
if (evaluation.severity === 'LOW') return;
|
||||
|
||||
const notifyRoles = evaluation.notifyRoles;
|
||||
|
||||
if (notifyRoles.length === 0) return;
|
||||
|
||||
// หา Users ที่มี role ที่ต้องการแจ้งเตือน
|
||||
const targetUsers = await this.userRepo
|
||||
.createQueryBuilder('user')
|
||||
.where('user.role IN (:...roles)', { roles: notifyRoles })
|
||||
.andWhere('user.is_active = 1')
|
||||
.getMany();
|
||||
|
||||
const codeLabel = responseCode.code;
|
||||
const title = `Response Code ${codeLabel} — Action Required`;
|
||||
const message = [
|
||||
`Document: ${documentNumber}`,
|
||||
`Response Code: ${codeLabel} — ${responseCode.descriptionEn}`,
|
||||
...evaluation.actionRequired,
|
||||
].join('\n');
|
||||
|
||||
// ส่งแจ้งเตือนแบบ parallel (ADR-008: ผ่าน BullMQ)
|
||||
await Promise.all(
|
||||
targetUsers.map((user: User) =>
|
||||
this.notificationService.send({
|
||||
userId: user.user_id,
|
||||
title,
|
||||
message,
|
||||
type: 'SYSTEM',
|
||||
entityType: 'rfa',
|
||||
entityId: rfaPublicId as unknown as number,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Triggered ${notifyRoles.length} role notifications for code ${codeLabel} on document ${documentNumber}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// File: src/modules/review-team/dto/shared/review-team.dto.ts
|
||||
// Shared DTOs สำหรับ Review Team และ Review Task APIs
|
||||
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsPositive,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ReviewTaskStatus,
|
||||
ReviewTeamMemberRole,
|
||||
DelegationScope,
|
||||
} from '../../../common/enums/review.enums';
|
||||
|
||||
// ─── Review Team DTOs ──────────────────────────────────────────────────────
|
||||
|
||||
export class CreateReviewTeamDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
projectPublicId!: string; // ADR-019: รับ publicId เสมอ
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
defaultForRfaTypes?: string[]; // ['SDW', 'DDW', 'ADW']
|
||||
}
|
||||
|
||||
export class UpdateReviewTeamDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
defaultForRfaTypes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class AddTeamMemberDto {
|
||||
@IsUUID()
|
||||
userPublicId!: string; // ADR-019
|
||||
|
||||
@IsUUID()
|
||||
disciplinePublicId!: string; // ADR-019
|
||||
|
||||
@IsEnum(ReviewTeamMemberRole)
|
||||
role!: ReviewTeamMemberRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
priorityOrder?: number;
|
||||
}
|
||||
|
||||
export class SearchReviewTeamDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectPublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ─── Review Task DTOs ──────────────────────────────────────────────────────
|
||||
|
||||
export class CompleteReviewTaskDto {
|
||||
@IsUUID()
|
||||
responseCodePublicId!: string; // ADR-019: รับ publicId
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
comments?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
attachmentPublicIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateReviewTaskStatusDto {
|
||||
@IsEnum(ReviewTaskStatus)
|
||||
status!: ReviewTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class SearchReviewTaskDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
rfaRevisionPublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ReviewTaskStatus)
|
||||
status?: ReviewTaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
assignedToUserPublicId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDateTo?: string;
|
||||
}
|
||||
|
||||
// ─── Delegation DTOs ───────────────────────────────────────────────────────
|
||||
|
||||
export class CreateDelegationDto {
|
||||
@IsUUID()
|
||||
delegateePublicId!: string; // ADR-019
|
||||
|
||||
@IsDateString()
|
||||
startDate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsEnum(DelegationScope)
|
||||
scope!: DelegationScope;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
documentTypes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ─── Veto Override DTO ─────────────────────────────────────────────────────
|
||||
|
||||
export class VetoOverrideDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(1000)
|
||||
justification!: string; // PM ต้องระบุเหตุผลที่ชัดเจน (min 10 chars)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// File: src/modules/review-team/entities/review-task.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
VersionColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { ReviewTeam } from './review-team.entity';
|
||||
import { ResponseCode } from '../../response-code/entities/response-code.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Discipline } from '../../master/entities/discipline.entity';
|
||||
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('review_tasks')
|
||||
export class ReviewTask extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'rfa_revision_id' })
|
||||
@Exclude()
|
||||
rfaRevisionId!: number;
|
||||
|
||||
@Column({ name: 'team_id' })
|
||||
@Exclude()
|
||||
teamId!: number;
|
||||
|
||||
@Column({ name: 'discipline_id' })
|
||||
@Exclude()
|
||||
disciplineId!: number;
|
||||
|
||||
@Column({ name: 'assigned_to_user_id', nullable: true })
|
||||
@Exclude()
|
||||
assignedToUserId?: number; // NULL = auto-assign ตาม discipline
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ReviewTaskStatus,
|
||||
default: ReviewTaskStatus.PENDING,
|
||||
})
|
||||
status!: ReviewTaskStatus;
|
||||
|
||||
@Column({ name: 'due_date', type: 'date', nullable: true })
|
||||
dueDate?: Date;
|
||||
|
||||
@Column({ name: 'response_code_id', nullable: true })
|
||||
@Exclude()
|
||||
responseCodeId?: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
comments?: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
attachments?: string[]; // Array ของ attachment publicIds
|
||||
|
||||
@Column({ name: 'delegated_from_user_id', nullable: true })
|
||||
@Exclude()
|
||||
delegatedFromUserId?: number; // ติดตาม delegation chain
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamp', nullable: true })
|
||||
completedAt?: Date;
|
||||
|
||||
@VersionColumn()
|
||||
version!: number; // Optimistic locking (ADR-002)
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReviewTeam)
|
||||
@JoinColumn({ name: 'team_id' })
|
||||
team?: ReviewTeam;
|
||||
|
||||
@ManyToOne(() => Discipline)
|
||||
@JoinColumn({ name: 'discipline_id' })
|
||||
discipline?: Discipline;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'assigned_to_user_id' })
|
||||
assignedToUser?: User;
|
||||
|
||||
@ManyToOne(() => ResponseCode)
|
||||
@JoinColumn({ name: 'response_code_id' })
|
||||
responseCode?: ResponseCode;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'delegated_from_user_id' })
|
||||
delegatedFromUser?: User;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// File: src/modules/review-team/entities/review-team-member.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Discipline } from '../../master/entities/discipline.entity';
|
||||
import { ReviewTeam } from './review-team.entity';
|
||||
import { ReviewTeamMemberRole } from '../../common/enums/review.enums';
|
||||
|
||||
@Entity('review_team_members')
|
||||
export class ReviewTeamMember extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'team_id' })
|
||||
@Exclude()
|
||||
teamId!: number;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
@Exclude()
|
||||
userId!: number;
|
||||
|
||||
@Column({ name: 'discipline_id' })
|
||||
@Exclude()
|
||||
disciplineId!: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ReviewTeamMemberRole,
|
||||
default: ReviewTeamMemberRole.REVIEWER,
|
||||
})
|
||||
role!: ReviewTeamMemberRole;
|
||||
|
||||
@Column({ name: 'priority_order', default: 0 })
|
||||
priorityOrder!: number; // สำหรับ fallback sequential assignment
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => ReviewTeam, (team: ReviewTeam) => team.members, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'team_id' })
|
||||
team!: ReviewTeam;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user?: User;
|
||||
|
||||
@ManyToOne(() => Discipline)
|
||||
@JoinColumn({ name: 'discipline_id' })
|
||||
discipline?: Discipline;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// File: src/modules/review-team/entities/review-team.entity.ts
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
import { ReviewTeamMember } from './review-team-member.entity';
|
||||
|
||||
@Entity('review_teams')
|
||||
export class ReviewTeam extends UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'project_id' })
|
||||
@Exclude()
|
||||
projectId!: number;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name!: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ name: 'default_for_rfa_types', type: 'simple-array', nullable: true })
|
||||
defaultForRfaTypes?: string[]; // Auto-assign ให้ RFA type เช่น ['SDW','DDW']
|
||||
|
||||
@Column({ name: 'is_active', type: 'tinyint', default: 1 })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project?: Project;
|
||||
|
||||
@OneToMany(() => ReviewTeamMember, (member: ReviewTeamMember) => member.team, { cascade: true })
|
||||
members?: ReviewTeamMember[];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// File: src/modules/review-team/review-task.service.ts
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTask } from './entities/review-task.entity';
|
||||
import { ResponseCode } from '../response-code/entities/response-code.entity';
|
||||
import {
|
||||
CompleteReviewTaskDto,
|
||||
SearchReviewTaskDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
import { ReviewTaskStatus } from '../common/enums/review.enums';
|
||||
import { validateTaskCompletionRequirements } from '../../common/validators/review-validators';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewTaskService {
|
||||
private readonly logger = new Logger(ReviewTaskService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
@InjectRepository(ResponseCode)
|
||||
private readonly responseCodeRepo: Repository<ResponseCode>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึง Tasks ทั้งหมดของ RFA Revision (internal use)
|
||||
*/
|
||||
async findByRevisionId(rfaRevisionId: number): Promise<ReviewTask[]> {
|
||||
return this.reviewTaskRepo.find({ where: { rfaRevisionId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* ค้นหา Review Tasks ตาม filter (FR-004)
|
||||
*/
|
||||
async findAll(dto: SearchReviewTaskDto): Promise<ReviewTask[]> {
|
||||
const qb = this.reviewTaskRepo
|
||||
.createQueryBuilder('task')
|
||||
.leftJoinAndSelect('task.discipline', 'discipline')
|
||||
.leftJoinAndSelect('task.assignedToUser', 'user')
|
||||
.leftJoinAndSelect('task.responseCode', 'responseCode')
|
||||
.leftJoinAndSelect('task.team', 'team');
|
||||
|
||||
if (dto.rfaRevisionPublicId) {
|
||||
qb.innerJoin('rfa_revisions', 'rev', 'rev.id = task.rfa_revision_id')
|
||||
.where('rev.uuid = :uuid', { uuid: dto.rfaRevisionPublicId });
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
qb.andWhere('task.status = :status', { status: dto.status });
|
||||
}
|
||||
|
||||
if (dto.assignedToUserPublicId) {
|
||||
qb.andWhere('user.uuid = :userUuid', { userUuid: dto.assignedToUserPublicId });
|
||||
}
|
||||
|
||||
if (dto.dueDateFrom) {
|
||||
qb.andWhere('task.due_date >= :from', { from: dto.dueDateFrom });
|
||||
}
|
||||
|
||||
if (dto.dueDateTo) {
|
||||
qb.andWhere('task.due_date <= :to', { to: dto.dueDateTo });
|
||||
}
|
||||
|
||||
return qb.orderBy('task.created_at', 'ASC').getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Review Task ตาม publicId (ADR-019)
|
||||
*/
|
||||
async findByPublicId(publicId: string): Promise<ReviewTask> {
|
||||
const task = await this.reviewTaskRepo.findOne({
|
||||
where: { publicId },
|
||||
relations: ['discipline', 'assignedToUser', 'responseCode', 'team'],
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Review Task not found: ${publicId}`);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Tasks รวมทั้งหมดของ RFA Revision พร้อม Aggregate Status (FR-004)
|
||||
*/
|
||||
async getAggregateStatus(rfaRevisionId: number): Promise<{
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
summary: string;
|
||||
}> {
|
||||
const tasks = await this.reviewTaskRepo.find({ where: { rfaRevisionId } });
|
||||
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(
|
||||
(t: ReviewTask) =>
|
||||
t.status === ReviewTaskStatus.COMPLETED || t.status === ReviewTaskStatus.CANCELLED,
|
||||
).length;
|
||||
const pending = total - completed;
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
pending,
|
||||
summary: `${completed} of ${total} Disciplines Reviewed`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* เริ่มตรวจสอบ Review Task (เปลี่ยน status จาก PENDING → IN_PROGRESS)
|
||||
*/
|
||||
async startReview(publicId: string): Promise<ReviewTask> {
|
||||
const task = await this.findByPublicId(publicId);
|
||||
|
||||
if (task.status !== ReviewTaskStatus.PENDING) {
|
||||
throw new BadRequestException(
|
||||
`Cannot start review: task is already ${task.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
task.status = ReviewTaskStatus.IN_PROGRESS;
|
||||
return this.reviewTaskRepo.save(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกผลการตรวจสอบ (FR-009, T069)
|
||||
* ใช้ Optimistic Locking (@VersionColumn) ป้องกัน race condition (ADR-002)
|
||||
*/
|
||||
async completeReview(publicId: string, dto: CompleteReviewTaskDto): Promise<ReviewTask> {
|
||||
const task = await this.findByPublicId(publicId);
|
||||
|
||||
if (
|
||||
task.status === ReviewTaskStatus.COMPLETED ||
|
||||
task.status === ReviewTaskStatus.CANCELLED
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Cannot complete review: task is already ${task.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ตรวจสอบ Response Code (ADR-019)
|
||||
const responseCode = await this.responseCodeRepo.findOne({
|
||||
where: { publicId: dto.responseCodePublicId },
|
||||
});
|
||||
|
||||
if (!responseCode) {
|
||||
throw new NotFoundException(
|
||||
`Response Code not found: ${dto.responseCodePublicId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate completion requirements (T073)
|
||||
validateTaskCompletionRequirements(
|
||||
ReviewTaskStatus.COMPLETED,
|
||||
responseCode.id,
|
||||
false, // requiresComments checked at controller level via ResponseCodeRule
|
||||
dto.comments,
|
||||
);
|
||||
|
||||
task.status = ReviewTaskStatus.COMPLETED;
|
||||
task.responseCodeId = responseCode.id;
|
||||
task.comments = dto.comments;
|
||||
task.attachments = dto.attachmentPublicIds;
|
||||
task.completedAt = new Date();
|
||||
|
||||
try {
|
||||
// TypeORM จะ throw OptimisticLockVersionMismatchError ถ้า version ไม่ตรง (ADR-002)
|
||||
return await this.reviewTaskRepo.save(task);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (errorMessage.includes('OptimisticLock') || errorMessage.includes('version')) {
|
||||
throw new ConflictException(
|
||||
'Review task was modified concurrently. Please refresh and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// File: src/modules/review-team/review-team.controller.ts
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ReviewTeamService } from './review-team.service';
|
||||
import {
|
||||
CreateReviewTeamDto,
|
||||
UpdateReviewTeamDto,
|
||||
AddTeamMemberDto,
|
||||
SearchReviewTeamDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
|
||||
@Controller('review-teams')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewTeamController {
|
||||
constructor(private readonly reviewTeamService: ReviewTeamService) {}
|
||||
|
||||
/**
|
||||
* GET /review-teams
|
||||
* ดึงรายการ Review Teams ตาม project
|
||||
*/
|
||||
@Get()
|
||||
findAll(@Query() dto: SearchReviewTeamDto) {
|
||||
return this.reviewTeamService.findAll(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /review-teams/:publicId
|
||||
* ดึง Review Team เดียว (ADR-019)
|
||||
*/
|
||||
@Get(':publicId')
|
||||
findOne(@Param('publicId') publicId: string) {
|
||||
return this.reviewTeamService.findByPublicId(publicId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /review-teams
|
||||
* สร้าง Review Team ใหม่
|
||||
*/
|
||||
@Post()
|
||||
create(@Body() dto: CreateReviewTeamDto) {
|
||||
return this.reviewTeamService.create(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /review-teams/:publicId
|
||||
* อัปเดต Review Team
|
||||
*/
|
||||
@Patch(':publicId')
|
||||
update(@Param('publicId') publicId: string, @Body() dto: UpdateReviewTeamDto) {
|
||||
return this.reviewTeamService.update(publicId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /review-teams/:publicId/members
|
||||
* เพิ่มสมาชิก
|
||||
*/
|
||||
@Post(':publicId/members')
|
||||
addMember(@Param('publicId') teamPublicId: string, @Body() dto: AddTeamMemberDto) {
|
||||
return this.reviewTeamService.addMember(teamPublicId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /review-teams/:publicId/members/:memberPublicId
|
||||
* ลบสมาชิก
|
||||
*/
|
||||
@Delete(':publicId/members/:memberPublicId')
|
||||
removeMember(
|
||||
@Param('publicId') teamPublicId: string,
|
||||
@Param('memberPublicId') memberPublicId: string,
|
||||
) {
|
||||
return this.reviewTeamService.removeMember(teamPublicId, memberPublicId);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /review-teams/:publicId
|
||||
* Deactivate Review Team (soft delete)
|
||||
*/
|
||||
@Delete(':publicId')
|
||||
deactivate(@Param('publicId') publicId: string) {
|
||||
return this.reviewTeamService.deactivate(publicId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// File: src/modules/review-team/review-team.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
|
||||
// Entities
|
||||
import { ReviewTeam } from './entities/review-team.entity';
|
||||
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
||||
import { ReviewTask } from './entities/review-task.entity';
|
||||
|
||||
// External entities needed for resolution
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
|
||||
// Services
|
||||
import { ReviewTeamService } from './review-team.service';
|
||||
import { ReviewTaskService } from './review-task.service';
|
||||
import { TaskCreationService } from './services/task-creation.service';
|
||||
import { AggregateStatusService } from './services/aggregate-status.service';
|
||||
import { ConsensusService } from './services/consensus.service';
|
||||
import { VetoOverrideService } from './services/veto-override.service';
|
||||
|
||||
// Controllers
|
||||
import { ReviewTeamController } from './review-team.controller';
|
||||
|
||||
// Modules
|
||||
import { ResponseCodeModule } from '../response-code/response-code.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { DistributionModule } from '../distribution/distribution.module';
|
||||
|
||||
// Queue constants
|
||||
import { QUEUE_REMINDERS, QUEUE_VETO_NOTIFICATIONS } from '../common/constants/queue.constants';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ReviewTeam, ReviewTeamMember, ReviewTask, User, Discipline]),
|
||||
BullModule.registerQueue(
|
||||
{ name: QUEUE_REMINDERS },
|
||||
{ name: QUEUE_VETO_NOTIFICATIONS },
|
||||
),
|
||||
ResponseCodeModule,
|
||||
NotificationModule,
|
||||
UserModule,
|
||||
DistributionModule,
|
||||
],
|
||||
providers: [
|
||||
ReviewTeamService,
|
||||
ReviewTaskService,
|
||||
TaskCreationService,
|
||||
AggregateStatusService,
|
||||
ConsensusService,
|
||||
VetoOverrideService,
|
||||
],
|
||||
controllers: [ReviewTeamController],
|
||||
exports: [
|
||||
ReviewTeamService,
|
||||
ReviewTaskService,
|
||||
TaskCreationService,
|
||||
AggregateStatusService,
|
||||
ConsensusService,
|
||||
VetoOverrideService,
|
||||
TypeOrmModule,
|
||||
],
|
||||
})
|
||||
export class ReviewTeamModule {}
|
||||
@@ -0,0 +1,188 @@
|
||||
// File: src/modules/review-team/review-team.service.ts
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTeam } from './entities/review-team.entity';
|
||||
import { ReviewTeamMember } from './entities/review-team-member.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
import {
|
||||
CreateReviewTeamDto,
|
||||
UpdateReviewTeamDto,
|
||||
AddTeamMemberDto,
|
||||
SearchReviewTeamDto,
|
||||
} from './dto/shared/review-team.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewTeamService {
|
||||
private readonly logger = new Logger(ReviewTeamService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTeam)
|
||||
private readonly teamRepo: Repository<ReviewTeam>,
|
||||
@InjectRepository(ReviewTeamMember)
|
||||
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
@InjectRepository(Discipline)
|
||||
private readonly disciplineRepo: Repository<Discipline>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ดึง Review Teams ตาม project (FR-001)
|
||||
*/
|
||||
async findAll(dto: SearchReviewTeamDto): Promise<ReviewTeam[]> {
|
||||
const qb = this.teamRepo
|
||||
.createQueryBuilder('team')
|
||||
.leftJoinAndSelect('team.members', 'member')
|
||||
.leftJoinAndSelect('member.user', 'user')
|
||||
.leftJoinAndSelect('member.discipline', 'discipline');
|
||||
|
||||
if (dto.projectPublicId) {
|
||||
qb.innerJoin('team.project', 'project').where('project.uuid = :uuid', {
|
||||
uuid: dto.projectPublicId,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.isActive !== undefined) {
|
||||
qb.andWhere('team.is_active = :isActive', { isActive: dto.isActive });
|
||||
}
|
||||
|
||||
if (dto.search) {
|
||||
qb.andWhere('team.name LIKE :search', { search: `%${dto.search}%` });
|
||||
}
|
||||
|
||||
return qb.orderBy('team.created_at', 'DESC').getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Review Team เดียวตาม publicId (ADR-019)
|
||||
*/
|
||||
async findByPublicId(publicId: string): Promise<ReviewTeam> {
|
||||
const team = await this.teamRepo.findOne({
|
||||
where: { publicId },
|
||||
relations: ['members', 'members.user', 'members.discipline', 'project'],
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Review Team not found: ${publicId}`);
|
||||
}
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึง Teams ที่เป็น Default สำหรับ RFA type นั้นๆ (FR-002)
|
||||
*/
|
||||
async findDefaultForRfaType(rfaTypeCode: string, projectId: number): Promise<ReviewTeam[]> {
|
||||
const teams = await this.teamRepo.find({
|
||||
where: { projectId, isActive: true },
|
||||
relations: ['members'],
|
||||
});
|
||||
|
||||
return teams.filter(
|
||||
(t: ReviewTeam) => t.defaultForRfaTypes?.includes(rfaTypeCode) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง Review Team ใหม่
|
||||
*/
|
||||
async create(dto: CreateReviewTeamDto): Promise<ReviewTeam> {
|
||||
// ตรวจสอบว่า project มีอยู่จริง (via publicId)
|
||||
const project = await this.teamRepo.manager.getRepository('projects').findOne({
|
||||
where: { uuid: dto.projectPublicId } as Record<string, unknown>,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project not found: ${dto.projectPublicId}`);
|
||||
}
|
||||
|
||||
const team = this.teamRepo.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
projectId: (project as { id: number }).id,
|
||||
defaultForRfaTypes: dto.defaultForRfaTypes,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return this.teamRepo.save(team);
|
||||
}
|
||||
|
||||
/**
|
||||
* อัปเดต Review Team
|
||||
*/
|
||||
async update(publicId: string, dto: UpdateReviewTeamDto): Promise<ReviewTeam> {
|
||||
const team = await this.findByPublicId(publicId);
|
||||
|
||||
if (dto.name !== undefined) team.name = dto.name;
|
||||
if (dto.description !== undefined) team.description = dto.description;
|
||||
if (dto.defaultForRfaTypes !== undefined) team.defaultForRfaTypes = dto.defaultForRfaTypes;
|
||||
if (dto.isActive !== undefined) team.isActive = dto.isActive;
|
||||
|
||||
return this.teamRepo.save(team);
|
||||
}
|
||||
|
||||
/**
|
||||
* เพิ่มสมาชิกใน Review Team (FR-001)
|
||||
*/
|
||||
async addMember(teamPublicId: string, dto: AddTeamMemberDto): Promise<ReviewTeamMember> {
|
||||
const team = await this.findByPublicId(teamPublicId);
|
||||
|
||||
// ตรวจสอบ User
|
||||
const user = await this.userRepo.findOne({ where: { publicId: dto.userPublicId } });
|
||||
if (!user) throw new NotFoundException(`User not found: ${dto.userPublicId}`);
|
||||
|
||||
// ตรวจสอบ Discipline
|
||||
const discipline = await this.disciplineRepo.findOne({
|
||||
where: { id: Number(dto.disciplinePublicId) },
|
||||
});
|
||||
if (!discipline) throw new NotFoundException(`Discipline not found: ${dto.disciplinePublicId}`);
|
||||
|
||||
// ตรวจสอบซ้ำ
|
||||
const existing = await this.memberRepo.findOne({
|
||||
where: { teamId: team.id, userId: user.user_id, disciplineId: discipline.id },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`User ${dto.userPublicId} is already a member of this team for discipline ${dto.disciplinePublicId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const member = this.memberRepo.create({
|
||||
teamId: team.id,
|
||||
userId: user.user_id,
|
||||
disciplineId: discipline.id,
|
||||
role: dto.role,
|
||||
priorityOrder: dto.priorityOrder ?? 0,
|
||||
});
|
||||
|
||||
return this.memberRepo.save(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบสมาชิกออกจาก Review Team
|
||||
*/
|
||||
async removeMember(teamPublicId: string, memberPublicId: string): Promise<void> {
|
||||
const team = await this.findByPublicId(teamPublicId);
|
||||
const member = await this.memberRepo.findOne({
|
||||
where: { publicId: memberPublicId, teamId: team.id },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException(`Member not found: ${memberPublicId}`);
|
||||
}
|
||||
|
||||
await this.memberRepo.remove(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* ลบ Review Team (soft delete ด้วย isActive = false)
|
||||
*/
|
||||
async deactivate(publicId: string): Promise<void> {
|
||||
const team = await this.findByPublicId(publicId);
|
||||
team.isActive = false;
|
||||
await this.teamRepo.save(team);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// File: src/modules/review-team/services/aggregate-status.service.ts
|
||||
// คำนวณสถานะรวมของ Review Tasks ภายใน RFA Revision (T067, FR-009)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { ReviewTaskStatus, ConsensusDecision } from '../../common/enums/review.enums';
|
||||
|
||||
export interface AggregateStatus {
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
delegated: number;
|
||||
expired: number;
|
||||
completionPct: number;
|
||||
isAllComplete: boolean;
|
||||
hasExpired: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AggregateStatusService {
|
||||
private readonly logger = new Logger(AggregateStatusService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly taskRepo: Repository<ReviewTask>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* คำนวณสถานะรวมของทุก Review Tasks ใน RFA Revision (FR-009)
|
||||
*/
|
||||
async getForRevision(rfaRevisionId: number): Promise<AggregateStatus> {
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId },
|
||||
select: ['id', 'status'],
|
||||
});
|
||||
|
||||
const counts = {
|
||||
total: tasks.length,
|
||||
completed: 0,
|
||||
pending: 0,
|
||||
inProgress: 0,
|
||||
delegated: 0,
|
||||
expired: 0,
|
||||
};
|
||||
|
||||
for (const task of tasks) {
|
||||
switch (task.status) {
|
||||
case ReviewTaskStatus.COMPLETED: counts.completed++; break;
|
||||
case ReviewTaskStatus.PENDING: counts.pending++; break;
|
||||
case ReviewTaskStatus.IN_PROGRESS: counts.inProgress++; break;
|
||||
case ReviewTaskStatus.DELEGATED: counts.delegated++; break;
|
||||
case ReviewTaskStatus.EXPIRED: counts.expired++; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
const completionPct =
|
||||
counts.total > 0
|
||||
? Math.round((counts.completed / counts.total) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...counts,
|
||||
completionPct,
|
||||
isAllComplete: counts.total > 0 && counts.completed === counts.total,
|
||||
hasExpired: counts.expired > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า RFA Revision พร้อมสำหรับ consensus หรือยัง (FR-010)
|
||||
*/
|
||||
async isReadyForConsensus(rfaRevisionId: number): Promise<boolean> {
|
||||
const status = await this.getForRevision(rfaRevisionId);
|
||||
return status.isAllComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine consensus based on response codes of completed tasks (FR-010)
|
||||
*/
|
||||
async evaluateConsensus(rfaRevisionId: number): Promise<ConsensusDecision> {
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
if (tasks.length === 0) return ConsensusDecision.PENDING;
|
||||
|
||||
// Veto check: any Code 3 = REJECTED
|
||||
const hasVeto = tasks.some((t) => t.responseCode?.code === '3');
|
||||
if (hasVeto) return ConsensusDecision.REJECTED;
|
||||
|
||||
// All approved: Code 1A or 1B = APPROVED
|
||||
const allApproved = tasks.every((t) =>
|
||||
['1A', '1B'].includes(t.responseCode?.code ?? ''),
|
||||
);
|
||||
if (allApproved) return ConsensusDecision.APPROVED;
|
||||
|
||||
// Any Code 2 = APPROVED_WITH_COMMENTS
|
||||
const hasComments = tasks.some((t) => t.responseCode?.code === '2');
|
||||
if (hasComments) return ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||
|
||||
return ConsensusDecision.APPROVED_WITH_COMMENTS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// File: src/modules/review-team/services/consensus.service.ts
|
||||
// Evaluate parallel review consensus และ trigger distribution (T068, FR-010)
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { AggregateStatusService } from './aggregate-status.service';
|
||||
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
|
||||
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
export interface ConsensusResult {
|
||||
decision: ConsensusDecision;
|
||||
completedTasks: number;
|
||||
totalTasks: number;
|
||||
triggeredDistribution: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ConsensusService {
|
||||
private readonly logger = new Logger(ConsensusService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly taskRepo: Repository<ReviewTask>,
|
||||
private readonly aggregateStatusService: AggregateStatusService,
|
||||
private readonly approvalListenerService: ApprovalListenerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* เรียกหลัง task complete — ตรวจสอบ consensus และ trigger distribution (FR-010)
|
||||
*/
|
||||
async evaluateAfterTaskComplete(
|
||||
rfaRevisionId: number,
|
||||
context: {
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
},
|
||||
): Promise<ConsensusResult> {
|
||||
const isReady = await this.aggregateStatusService.isReadyForConsensus(rfaRevisionId);
|
||||
|
||||
const status = await this.aggregateStatusService.getForRevision(rfaRevisionId);
|
||||
|
||||
if (!isReady) {
|
||||
this.logger.debug(
|
||||
`Revision ${rfaRevisionId}: ${status.completed}/${status.total} tasks done — not ready for consensus`,
|
||||
);
|
||||
return {
|
||||
decision: ConsensusDecision.PENDING,
|
||||
completedTasks: status.completed,
|
||||
totalTasks: status.total,
|
||||
triggeredDistribution: false,
|
||||
};
|
||||
}
|
||||
|
||||
const decision = await this.aggregateStatusService.evaluateConsensus(rfaRevisionId);
|
||||
|
||||
this.logger.log(
|
||||
`Revision ${rfaRevisionId}: consensus = ${decision} (${status.total} tasks)`,
|
||||
);
|
||||
|
||||
let triggeredDistribution = false;
|
||||
if (
|
||||
decision === ConsensusDecision.APPROVED ||
|
||||
decision === ConsensusDecision.APPROVED_WITH_COMMENTS
|
||||
) {
|
||||
// ดึง response code ที่ predominant
|
||||
const completedTasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId, status: ReviewTaskStatus.COMPLETED },
|
||||
relations: ['responseCode'],
|
||||
order: { completedAt: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
const responseCode = completedTasks[0]?.responseCode?.code ?? '1A';
|
||||
|
||||
await this.approvalListenerService.onConsensusReached({
|
||||
...context,
|
||||
responseCode,
|
||||
decision,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
|
||||
triggeredDistribution = true;
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
completedTasks: status.completed,
|
||||
totalTasks: status.total,
|
||||
triggeredDistribution,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// File: src/modules/review-team/services/task-creation.service.ts
|
||||
// Strangler Pattern: แยก logic การสร้าง Parallel Review Tasks ออกจาก rfa.service.ts
|
||||
// เรียกใช้หลังจาก RFA Submit สำเร็จ (T017 integration)
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, EntityManager } from 'typeorm';
|
||||
import { ReviewTeam } from '../entities/review-team.entity';
|
||||
import { ReviewTeamMember } from '../entities/review-team-member.entity';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
@Injectable()
|
||||
export class TaskCreationService {
|
||||
private readonly logger = new Logger(TaskCreationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTeam)
|
||||
private readonly reviewTeamRepo: Repository<ReviewTeam>,
|
||||
@InjectRepository(ReviewTeamMember)
|
||||
private readonly memberRepo: Repository<ReviewTeamMember>,
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly reviewTaskRepo: Repository<ReviewTask>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* สร้าง Parallel Review Tasks สำหรับแต่ละ Discipline ใน Review Team (FR-003)
|
||||
* เรียกใช้ภายใน Transaction ของ rfa.service.ts submit method
|
||||
*
|
||||
* @param rfaRevisionId - Internal ID ของ RFA Revision
|
||||
* @param reviewTeamPublicId - publicId ของ Review Team (ADR-019)
|
||||
* @param dueDate - กำหนดเวลาตรวจสอบ
|
||||
* @param manager - EntityManager จาก QueryRunner (ใช้ Transaction เดิม)
|
||||
*/
|
||||
async createParallelTasks(
|
||||
rfaRevisionId: number,
|
||||
reviewTeamPublicId: string,
|
||||
dueDate: Date,
|
||||
manager: EntityManager,
|
||||
): Promise<ReviewTask[]> {
|
||||
// ดึง ReviewTeam พร้อม members
|
||||
const team = await this.reviewTeamRepo.findOne({
|
||||
where: { publicId: reviewTeamPublicId },
|
||||
relations: ['members'],
|
||||
});
|
||||
|
||||
if (!team || !team.isActive) {
|
||||
this.logger.warn(
|
||||
`ReviewTeam ${reviewTeamPublicId} not found or inactive — skipping task creation`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const members = team.members ?? [];
|
||||
|
||||
if (members.length === 0) {
|
||||
this.logger.warn(
|
||||
`ReviewTeam ${reviewTeamPublicId} has no members — skipping task creation`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// กลุ่ม members ตาม disciplineId (แต่ละ Discipline ต้องการเพียง 1 Task)
|
||||
const disciplineMap = new Map<number, ReviewTeamMember>();
|
||||
for (const member of members) {
|
||||
// LEAD มี priority สูงสุด ถ้ามีหลายคนใน Discipline เดียวกัน
|
||||
const existing = disciplineMap.get(member.disciplineId);
|
||||
if (!existing || member.role === 'LEAD') {
|
||||
disciplineMap.set(member.disciplineId, member);
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: ReviewTask[] = [];
|
||||
|
||||
// สร้าง ReviewTask สำหรับแต่ละ Discipline พร้อมกัน (Parallel)
|
||||
for (const [disciplineId, leadMember] of disciplineMap) {
|
||||
const task = manager.create(ReviewTask, {
|
||||
rfaRevisionId,
|
||||
teamId: team.id,
|
||||
disciplineId,
|
||||
assignedToUserId: leadMember.userId,
|
||||
status: ReviewTaskStatus.PENDING,
|
||||
dueDate,
|
||||
});
|
||||
const saved = await manager.save(ReviewTask, task);
|
||||
tasks.push(saved);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Created ${tasks.length} parallel review tasks for RFA revision ${rfaRevisionId}, team ${reviewTeamPublicId}`,
|
||||
);
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า RFA Revision มี Review Tasks ครบทุก Discipline แล้วหรือยัง
|
||||
*/
|
||||
async areAllTasksCompleted(rfaRevisionId: number): Promise<boolean> {
|
||||
const tasks = await this.reviewTaskRepo.find({
|
||||
where: { rfaRevisionId },
|
||||
});
|
||||
|
||||
if (tasks.length === 0) return false;
|
||||
|
||||
return tasks.every(
|
||||
(t: ReviewTask) =>
|
||||
t.status === ReviewTaskStatus.COMPLETED ||
|
||||
t.status === ReviewTaskStatus.CANCELLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// File: src/modules/review-team/services/veto-override.service.ts
|
||||
// PM Veto Override — บังคับผ่าน RFA Revision แม้มี Code 3 (T068.5)
|
||||
import { Injectable, Logger, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { ReviewTask } from '../entities/review-task.entity';
|
||||
import { ApprovalListenerService } from '../../distribution/services/approval-listener.service';
|
||||
import { ConsensusDecision, ReviewTaskStatus } from '../../common/enums/review.enums';
|
||||
|
||||
export interface VetoOverrideDto {
|
||||
rfaRevisionId: number;
|
||||
rfaPublicId: string;
|
||||
rfaRevisionPublicId: string;
|
||||
projectId: number;
|
||||
documentTypeCode: string;
|
||||
overrideReason: string;
|
||||
overriddenByUserId: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VetoOverrideService {
|
||||
private readonly logger = new Logger(VetoOverrideService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ReviewTask)
|
||||
private readonly taskRepo: Repository<ReviewTask>,
|
||||
private readonly approvalListenerService: ApprovalListenerService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* PM Override: บังคับ APPROVED แม้ว่าจะมี Code 3 rejection (FR-012)
|
||||
* ต้องมี justification reason และ audit trail
|
||||
*/
|
||||
async executeOverride(dto: VetoOverrideDto): Promise<{ decision: ConsensusDecision }> {
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { rfaRevisionId: dto.rfaRevisionId },
|
||||
relations: ['responseCode'],
|
||||
});
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw new NotFoundException(`No review tasks found for revision ${dto.rfaRevisionId}`);
|
||||
}
|
||||
|
||||
const hasVeto = tasks.some((t) => t.responseCode?.code === '3');
|
||||
if (!hasVeto) {
|
||||
throw new ForbiddenException('No Code 3 veto found — override not needed');
|
||||
}
|
||||
|
||||
if (!dto.overrideReason || dto.overrideReason.trim().length < 10) {
|
||||
throw new ForbiddenException('Override reason must be at least 10 characters');
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`PM Override executed by user ${dto.overriddenByUserId} for revision ${dto.rfaRevisionId}. Reason: ${dto.overrideReason}`,
|
||||
);
|
||||
|
||||
await this.approvalListenerService.onConsensusReached({
|
||||
rfaPublicId: dto.rfaPublicId,
|
||||
rfaRevisionPublicId: dto.rfaRevisionPublicId,
|
||||
projectId: dto.projectId,
|
||||
documentTypeCode: dto.documentTypeCode,
|
||||
responseCode: '1A',
|
||||
decision: ConsensusDecision.OVERRIDDEN,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
|
||||
return { decision: ConsensusDecision.OVERRIDDEN };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// File: src/modules/rfa/dto/submit-rfa.dto.ts
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, IsNotEmpty } from 'class-validator';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class SubmitRfaDto {
|
||||
@ApiProperty({
|
||||
@@ -10,4 +10,13 @@ export class SubmitRfaDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
templateId!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'publicId ของ Review Team สำหรับ Parallel Review (ADR-019)',
|
||||
example: '019505a1-7c3e-7000-8000-abc123def456',
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
reviewTeamPublicId?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// File: src/modules/workflow-engine/dsl/parallel-gateway.handler.ts
|
||||
// Parallel Gateway DSL handler สำหรับ RFA parallel review (T066, ADR-001)
|
||||
// Strangler Pattern: ขยาย WorkflowEngine โดยไม่แก้ไข core DSL
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ParallelGatewayStep {
|
||||
type: 'parallel_gateway';
|
||||
id: string;
|
||||
branches: ParallelBranch[];
|
||||
completionStrategy: 'ALL' | 'MAJORITY' | 'ANY';
|
||||
onComplete: string; // next step ID
|
||||
}
|
||||
|
||||
export interface ParallelBranch {
|
||||
id: string;
|
||||
assigneeType: 'DISCIPLINE' | 'USER' | 'TEAM';
|
||||
assigneeId: string; // publicId
|
||||
steps: string[]; // step IDs within this branch
|
||||
}
|
||||
|
||||
export interface GatewayExecutionContext {
|
||||
rfaRevisionPublicId: string;
|
||||
completedBranches: Set<string>;
|
||||
totalBranches: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ParallelGatewayHandler {
|
||||
private readonly logger = new Logger(ParallelGatewayHandler.name);
|
||||
|
||||
/**
|
||||
* ตรวจสอบว่า gateway สามารถเดินหน้าได้หรือยัง ตาม completionStrategy (FR-008)
|
||||
*/
|
||||
canAdvance(step: ParallelGatewayStep, ctx: GatewayExecutionContext): boolean {
|
||||
const { completedBranches, totalBranches } = ctx;
|
||||
|
||||
switch (step.completionStrategy) {
|
||||
case 'ALL':
|
||||
return completedBranches.size === totalBranches;
|
||||
|
||||
case 'MAJORITY':
|
||||
return completedBranches.size > Math.floor(totalBranches / 2);
|
||||
|
||||
case 'ANY':
|
||||
return completedBranches.size >= 1;
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown completion strategy: ${step.completionStrategy as string}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* สร้าง execution context จาก gateway definition
|
||||
*/
|
||||
createContext(
|
||||
rfaRevisionPublicId: string,
|
||||
step: ParallelGatewayStep,
|
||||
): GatewayExecutionContext {
|
||||
return {
|
||||
rfaRevisionPublicId,
|
||||
completedBranches: new Set<string>(),
|
||||
totalBranches: step.branches.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a branch complete and check if gateway can advance
|
||||
*/
|
||||
markBranchComplete(
|
||||
ctx: GatewayExecutionContext,
|
||||
branchId: string,
|
||||
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}`,
|
||||
);
|
||||
|
||||
return { canAdvance, completedCount: ctx.completedBranches.size };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user