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:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user