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:
Nattanin
2026-05-12 16:17:27 +07:00
parent 3df8707b7f
commit ef20839f99
82 changed files with 7052 additions and 104 deletions
@@ -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}`,
);
}
}