251126:1300 test run

This commit is contained in:
2025-11-26 14:38:24 +07:00
parent 0a0c6645d5
commit 304f7fddf6
12 changed files with 447 additions and 271 deletions

View File

@@ -21,6 +21,10 @@
"username": "root" "username": "root"
} }
], ],
"editor.fontSize": 15 "editor.fontSize": 15,
"editor.codeActionsOnSave": {
},
"editor.codeActions.triggerOnFocusChange": true
} }
} }

View File

@@ -18,7 +18,7 @@ import { CorrespondenceType } from './entities/correspondence-type.entity.js';
import { CorrespondenceStatus } from './entities/correspondence-status.entity.js'; import { CorrespondenceStatus } from './entities/correspondence-status.entity.js';
import { RoutingTemplate } from './entities/routing-template.entity.js'; import { RoutingTemplate } from './entities/routing-template.entity.js';
import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js'; import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js';
import { CorrespondenceReference } from './entities/correspondence-reference.entity.js'; // Entity สำหรับตารางเชื่อมโยง import { CorrespondenceReference } from './entities/correspondence-reference.entity.js';
import { User } from '../user/entities/user.entity.js'; import { User } from '../user/entities/user.entity.js';
// DTOs // DTOs
@@ -35,7 +35,8 @@ import { DocumentNumberingService } from '../document-numbering/document-numberi
import { JsonSchemaService } from '../json-schema/json-schema.service.js'; import { JsonSchemaService } from '../json-schema/json-schema.service.js';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js'; import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js';
import { UserService } from '../user/user.service.js'; import { UserService } from '../user/user.service.js';
import { SearchService } from '../search/search.service'; // Import SearchService import { SearchService } from '../search/search.service.js';
@Injectable() @Injectable()
export class CorrespondenceService { export class CorrespondenceService {
private readonly logger = new Logger(CorrespondenceService.name); private readonly logger = new Logger(CorrespondenceService.name);
@@ -61,19 +62,10 @@ export class CorrespondenceService {
private workflowEngine: WorkflowEngineService, private workflowEngine: WorkflowEngineService,
private userService: UserService, private userService: UserService,
private dataSource: DataSource, private dataSource: DataSource,
private searchService: SearchService, // Inject private searchService: SearchService,
) {} ) {}
/**
* สร้างเอกสารใหม่ (Create Document)
* รองรับ Impersonation Logic: Superadmin สามารถสร้างในนามองค์กรอื่นได้
*
* @param createDto ข้อมูลสำหรับการสร้างเอกสาร
* @param user ผู้ใช้งานที่ทำการสร้าง
* @returns ข้อมูลเอกสารที่สร้างเสร็จแล้ว
*/
async create(createDto: CreateCorrespondenceDto, user: User) { async create(createDto: CreateCorrespondenceDto, user: User) {
// 1. ตรวจสอบข้อมูลพื้นฐาน (Basic Validation)
const type = await this.typeRepo.findOne({ const type = await this.typeRepo.findOne({
where: { id: createDto.typeId }, where: { id: createDto.typeId },
}); });
@@ -88,11 +80,8 @@ export class CorrespondenceService {
); );
} }
// 2. Impersonation Logic & Organization Context
// กำหนด Org เริ่มต้นเป็นของผู้ใช้งานปัจจุบัน
let userOrgId = user.primaryOrganizationId; let userOrgId = user.primaryOrganizationId;
// Fallback: หากใน Token ไม่มี Org ID ให้ดึงจาก DB อีกครั้งเพื่อความชัวร์
if (!userOrgId) { if (!userOrgId) {
const fullUser = await this.userService.findOne(user.user_id); const fullUser = await this.userService.findOne(user.user_id);
if (fullUser) { if (fullUser) {
@@ -100,91 +89,82 @@ export class CorrespondenceService {
} }
} }
// ตรวจสอบกรณีต้องการสร้างในนามองค์กรอื่น (Impersonation) // Impersonation Logic
if (createDto.originatorId && createDto.originatorId !== userOrgId) { if (createDto.originatorId && createDto.originatorId !== userOrgId) {
// ดึง Permissions ของผู้ใช้มาตรวจสอบ
const permissions = await this.userService.getUserPermissions( const permissions = await this.userService.getUserPermissions(
user.user_id, user.user_id,
); );
// ผู้ใช้ต้องมีสิทธิ์ 'system.manage_all' เท่านั้นจึงจะสวมสิทธิ์ได้
if (!permissions.includes('system.manage_all')) { if (!permissions.includes('system.manage_all')) {
throw new ForbiddenException( throw new ForbiddenException(
'You do not have permission to create documents on behalf of other organizations.', 'You do not have permission to create documents on behalf of other organizations.',
); );
} }
// อนุญาตให้ใช้ Org ID ที่ส่งมา
userOrgId = createDto.originatorId; userOrgId = createDto.originatorId;
} }
// Final Validation: ต้องมี Org ID เสมอ
if (!userOrgId) { if (!userOrgId) {
throw new BadRequestException( throw new BadRequestException(
'User must belong to an organization to create documents', 'User must belong to an organization to create documents',
); );
} }
// 3. Validate JSON Details (ถ้ามี)
if (createDto.details) { if (createDto.details) {
try { try {
await this.jsonSchemaService.validate(type.typeCode, createDto.details); await this.jsonSchemaService.validate(type.typeCode, createDto.details);
} catch (error: any) { } catch (error: any) {
// Log warning แต่ไม่ Block การสร้าง (ตามความยืดหยุ่นที่ต้องการ) หรือจะ Throw ก็ได้ตาม Req
this.logger.warn( this.logger.warn(
`Schema validation warning for ${type.typeCode}: ${error.message}`, `Schema validation warning for ${type.typeCode}: ${error.message}`,
); );
} }
} }
// 4. เริ่ม Transaction (เพื่อความสมบูรณ์ของข้อมูล: เลขที่เอกสาร + ตัวเอกสาร + Revision)
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
// 4.1 ขอเลขที่เอกสาร (Double-Lock Mechanism ผ่าน NumberingService) const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity
// TODO: Fetch ORG_CODE จาก DB จริงๆ โดยใช้ userOrgId
const orgCode = 'ORG'; // Mock ไว้ก่อน ควร query จาก Organization Entity
const docNumber = await this.numberingService.generateNextNumber( // [FIXED] เรียกใช้แบบ Object Context ตาม Requirement 6B
createDto.projectId, const docNumber = await this.numberingService.generateNextNumber({
userOrgId, // ใช้ ID ของเจ้าของเอกสารจริง (Originator) projectId: createDto.projectId,
createDto.typeId, originatorId: userOrgId,
new Date().getFullYear(), typeId: createDto.typeId,
{ disciplineId: createDto.disciplineId, // ส่ง Discipline (ถ้ามี)
subTypeId: createDto.subTypeId, // ส่ง SubType (ถ้ามี)
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode, TYPE_CODE: type.typeCode,
ORG_CODE: orgCode, ORG_CODE: orgCode,
}, },
); });
// 4.2 สร้าง Correspondence (หัวจดหมาย)
const correspondence = queryRunner.manager.create(Correspondence, { const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber, correspondenceNumber: docNumber,
correspondenceTypeId: createDto.typeId, correspondenceTypeId: createDto.typeId,
disciplineId: createDto.disciplineId, // บันทึก Discipline ลง DB
projectId: createDto.projectId, projectId: createDto.projectId,
originatorId: userOrgId, // บันทึก Org ที่ถูกต้อง originatorId: userOrgId,
isInternal: createDto.isInternal || false, isInternal: createDto.isInternal || false,
createdBy: user.user_id, createdBy: user.user_id,
}); });
const savedCorr = await queryRunner.manager.save(correspondence); const savedCorr = await queryRunner.manager.save(correspondence);
// 4.3 สร้าง Revision แรก (Rev 0)
const revision = queryRunner.manager.create(CorrespondenceRevision, { const revision = queryRunner.manager.create(CorrespondenceRevision, {
correspondenceId: savedCorr.id, correspondenceId: savedCorr.id,
revisionNumber: 0, revisionNumber: 0,
revisionLabel: 'A', // หรือเริ่มที่ 0 แล้วแต่ Business Logic revisionLabel: 'A',
isCurrent: true, isCurrent: true,
statusId: statusDraft.id, statusId: statusDraft.id,
title: createDto.title, title: createDto.title,
description: createDto.description, // ถ้ามีใน DTO description: createDto.description,
details: createDto.details, details: createDto.details,
createdBy: user.user_id, createdBy: user.user_id,
}); });
await queryRunner.manager.save(revision); await queryRunner.manager.save(revision);
await queryRunner.commitTransaction(); // Transaction จบแล้ว ข้อมูลชัวร์แล้ว await queryRunner.commitTransaction();
// 🔥 Fire & Forget: ไม่ต้อง await ผลลัพธ์เพื่อความเร็ว (หรือใช้ Queue ก็ได้)
this.searchService.indexDocument({ this.searchService.indexDocument({
id: savedCorr.id, id: savedCorr.id,
type: 'correspondence', type: 'correspondence',
@@ -211,10 +191,7 @@ export class CorrespondenceService {
} }
} }
/** // ... (method อื่นๆ คงเดิม)
* ค้นหาเอกสาร (Find All)
* รองรับการกรองและค้นหา
*/
async findAll(searchDto: SearchCorrespondenceDto = {}) { async findAll(searchDto: SearchCorrespondenceDto = {}) {
const { search, typeId, projectId, statusId } = searchDto; const { search, typeId, projectId, statusId } = searchDto;
@@ -224,7 +201,7 @@ export class CorrespondenceService {
.leftJoinAndSelect('corr.type', 'type') .leftJoinAndSelect('corr.type', 'type')
.leftJoinAndSelect('corr.project', 'project') .leftJoinAndSelect('corr.project', 'project')
.leftJoinAndSelect('corr.originator', 'org') .leftJoinAndSelect('corr.originator', 'org')
.where('rev.isCurrent = :isCurrent', { isCurrent: true }); // ดูเฉพาะ Rev ปัจจุบัน .where('rev.isCurrent = :isCurrent', { isCurrent: true });
if (projectId) { if (projectId) {
query.andWhere('corr.projectId = :projectId', { projectId }); query.andWhere('corr.projectId = :projectId', { projectId });
@@ -250,21 +227,15 @@ export class CorrespondenceService {
return query.getMany(); return query.getMany();
} }
/**
* ดึงข้อมูลเอกสารรายตัว (Find One)
* พร้อม Relations ที่จำเป็น
*/
async findOne(id: number) { async findOne(id: number) {
const correspondence = await this.correspondenceRepo.findOne({ const correspondence = await this.correspondenceRepo.findOne({
where: { id }, where: { id },
relations: [ relations: [
'revisions', 'revisions',
'revisions.status', // สถานะของ Revision 'revisions.status',
'type', 'type',
'project', 'project',
'originator', 'originator',
// 'tags', // ถ้ามี Relation
// 'attachments' // ถ้ามี Relation ผ่าน Junction
], ],
}); });
@@ -274,10 +245,6 @@ export class CorrespondenceService {
return correspondence; return correspondence;
} }
/**
* ส่งเอกสารเข้า Workflow (Submit)
* สร้าง Routing เริ่มต้นตาม Template
*/
async submit(correspondenceId: number, templateId: number, user: User) { async submit(correspondenceId: number, templateId: number, user: User) {
const correspondence = await this.correspondenceRepo.findOne({ const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId }, where: { id: correspondenceId },
@@ -293,9 +260,6 @@ export class CorrespondenceService {
throw new NotFoundException('Current revision not found'); throw new NotFoundException('Current revision not found');
} }
// ตรวจสอบสถานะปัจจุบัน (ต้องเป็น DRAFT หรือสถานะที่แก้ได้)
// TODO: เพิ่ม Logic ตรวจสอบ Status ID ว่าเป็น DRAFT หรือไม่
const template = await this.templateRepo.findOne({ const template = await this.templateRepo.findOne({
where: { id: templateId }, where: { id: templateId },
relations: ['steps'], relations: ['steps'],
@@ -315,25 +279,22 @@ export class CorrespondenceService {
try { try {
const firstStep = template.steps[0]; const firstStep = template.steps[0];
// สร้าง Routing Record แรก
const routing = queryRunner.manager.create(CorrespondenceRouting, { const routing = queryRunner.manager.create(CorrespondenceRouting, {
correspondenceId: currentRevision.id, // ผูกกับ Revision correspondenceId: currentRevision.id,
templateId: template.id, // บันทึก templateId ไว้ใช้อ้างอิง templateId: template.id,
sequence: 1, sequence: 1,
fromOrganizationId: user.primaryOrganizationId, // ส่งจากเรา fromOrganizationId: user.primaryOrganizationId,
toOrganizationId: firstStep.toOrganizationId, // ไปยังผู้รับคนแรก toOrganizationId: firstStep.toOrganizationId,
stepPurpose: firstStep.stepPurpose, stepPurpose: firstStep.stepPurpose,
status: 'SENT', status: 'SENT',
dueDate: new Date( dueDate: new Date(
Date.now() + (firstStep.expectedDays || 7) * 24 * 60 * 60 * 1000, Date.now() + (firstStep.expectedDays || 7) * 24 * 60 * 60 * 1000,
), ),
processedByUserId: user.user_id, // บันทึกว่าใครกดส่ง processedByUserId: user.user_id,
processedAt: new Date(), processedAt: new Date(),
}); });
await queryRunner.manager.save(routing); await queryRunner.manager.save(routing);
// TODO: อัปเดตสถานะเอกสารเป็น SUBMITTED (เปลี่ยน statusId ใน Revision)
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
return routing; return routing;
} catch (err) { } catch (err) {
@@ -344,15 +305,11 @@ export class CorrespondenceService {
} }
} }
/**
* ประมวลผล Action ใน Workflow (Approve/Reject/Etc.)
*/
async processAction( async processAction(
correspondenceId: number, correspondenceId: number,
dto: WorkflowActionDto, dto: WorkflowActionDto,
user: User, user: User,
) { ) {
// 1. Find Document & Current Revision
const correspondence = await this.correspondenceRepo.findOne({ const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId }, where: { id: correspondenceId },
relations: ['revisions'], relations: ['revisions'],
@@ -365,8 +322,6 @@ export class CorrespondenceService {
if (!currentRevision) if (!currentRevision)
throw new NotFoundException('Current revision not found'); throw new NotFoundException('Current revision not found');
// 2. Find Active Routing Step (Status = SENT)
// หาสเต็ปล่าสุดที่ส่งมาถึง Org ของเรา และสถานะเป็น SENT
const currentRouting = await this.routingRepo.findOne({ const currentRouting = await this.routingRepo.findOne({
where: { where: {
correspondenceId: currentRevision.id, correspondenceId: currentRevision.id,
@@ -382,16 +337,12 @@ export class CorrespondenceService {
); );
} }
// 3. Check Permissions (Must be in target Org)
// Logic: ผู้กด Action ต้องสังกัด Org ที่เป็นปลายทางของ Routing นี้
// TODO: เพิ่ม Logic ให้ Superadmin หรือ Document Control กดแทนได้
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) { if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
throw new BadRequestException( throw new BadRequestException(
'You are not authorized to process this step', 'You are not authorized to process this step',
); );
} }
// 4. Load Template to find Next Step Config
if (!currentRouting.templateId) { if (!currentRouting.templateId) {
throw new InternalServerErrorException( throw new InternalServerErrorException(
'Routing record missing templateId', 'Routing record missing templateId',
@@ -410,7 +361,6 @@ export class CorrespondenceService {
const totalSteps = template.steps.length; const totalSteps = template.steps.length;
const currentSeq = currentRouting.sequence; const currentSeq = currentRouting.sequence;
// 5. Calculate Next State using Workflow Engine Service
const result = this.workflowEngine.processAction( const result = this.workflowEngine.processAction(
currentSeq, currentSeq,
totalSteps, totalSteps,
@@ -418,13 +368,11 @@ export class CorrespondenceService {
dto.returnToSequence, dto.returnToSequence,
); );
// 6. Execute Database Updates
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
// 6.1 Update Current Step
currentRouting.status = currentRouting.status =
dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED'; dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
currentRouting.processedByUserId = user.user_id; currentRouting.processedByUserId = user.user_id;
@@ -433,15 +381,12 @@ export class CorrespondenceService {
await queryRunner.manager.save(currentRouting); await queryRunner.manager.save(currentRouting);
// 6.2 Create Next Step (If exists and not rejected/completed)
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) { if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
// ค้นหา Config ของ Step ถัดไปจาก Template
const nextStepConfig = template.steps.find( const nextStepConfig = template.steps.find(
(s) => s.sequence === result.nextStepSequence, (s) => s.sequence === result.nextStepSequence,
); );
if (!nextStepConfig) { if (!nextStepConfig) {
// อาจจะเป็นกรณี End of Workflow หรือ Logic Error
this.logger.warn( this.logger.warn(
`Next step ${result.nextStepSequence} not found in template`, `Next step ${result.nextStepSequence} not found in template`,
); );
@@ -452,8 +397,8 @@ export class CorrespondenceService {
correspondenceId: currentRevision.id, correspondenceId: currentRevision.id,
templateId: template.id, templateId: template.id,
sequence: result.nextStepSequence, sequence: result.nextStepSequence,
fromOrganizationId: user.primaryOrganizationId, // ส่งจากคนปัจจุบัน fromOrganizationId: user.primaryOrganizationId,
toOrganizationId: nextStepConfig.toOrganizationId, // ไปยังคนถัดไปตาม Template toOrganizationId: nextStepConfig.toOrganizationId,
stepPurpose: nextStepConfig.stepPurpose, stepPurpose: nextStepConfig.stepPurpose,
status: 'SENT', status: 'SENT',
dueDate: new Date( dueDate: new Date(
@@ -466,12 +411,6 @@ export class CorrespondenceService {
} }
} }
// 6.3 Update Document Status (Optional / Based on result)
if (result.shouldUpdateStatus) {
// Logic เปลี่ยนสถานะ revision เช่นจาก SUBMITTED -> APPROVED
// await this.updateDocumentStatus(currentRevision, result.documentStatus, queryRunner);
}
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
return { message: 'Action processed successfully', result }; return { message: 'Action processed successfully', result };
} catch (err) { } catch (err) {
@@ -482,14 +421,7 @@ export class CorrespondenceService {
} }
} }
// --- REFERENCE MANAGEMENT ---
/**
* เพิ่มเอกสารอ้างอิง (Add Reference)
* ตรวจสอบ Circular Reference และ Duplicate
*/
async addReference(id: number, dto: AddReferenceDto) { async addReference(id: number, dto: AddReferenceDto) {
// 1. เช็คว่าเอกสารทั้งคู่มีอยู่จริง
const source = await this.correspondenceRepo.findOne({ where: { id } }); const source = await this.correspondenceRepo.findOne({ where: { id } });
const target = await this.correspondenceRepo.findOne({ const target = await this.correspondenceRepo.findOne({
where: { id: dto.targetId }, where: { id: dto.targetId },
@@ -499,12 +431,10 @@ export class CorrespondenceService {
throw new NotFoundException('Source or Target correspondence not found'); throw new NotFoundException('Source or Target correspondence not found');
} }
// 2. ป้องกันการอ้างอิงตัวเอง (Self-Reference)
if (source.id === target.id) { if (source.id === target.id) {
throw new BadRequestException('Cannot reference self'); throw new BadRequestException('Cannot reference self');
} }
// 3. ตรวจสอบว่ามีอยู่แล้วหรือไม่ (Duplicate Check)
const exists = await this.referenceRepo.findOne({ const exists = await this.referenceRepo.findOne({
where: { where: {
sourceId: id, sourceId: id,
@@ -513,10 +443,9 @@ export class CorrespondenceService {
}); });
if (exists) { if (exists) {
return exists; // ถ้ามีแล้วก็คืนตัวเดิมไป (Idempotency) return exists;
} }
// 4. สร้าง Reference
const ref = this.referenceRepo.create({ const ref = this.referenceRepo.create({
sourceId: id, sourceId: id,
targetId: dto.targetId, targetId: dto.targetId,
@@ -525,9 +454,6 @@ export class CorrespondenceService {
return this.referenceRepo.save(ref); return this.referenceRepo.save(ref);
} }
/**
* ลบเอกสารอ้างอิง (Remove Reference)
*/
async removeReference(id: number, targetId: number) { async removeReference(id: number, targetId: number) {
const result = await this.referenceRepo.delete({ const result = await this.referenceRepo.delete({
sourceId: id, sourceId: id,
@@ -539,21 +465,15 @@ export class CorrespondenceService {
} }
} }
/**
* ดึงรายการเอกสารอ้างอิง (Get References)
* ทั้งที่อ้างถึง (Outgoing) และถูกอ้างถึง (Incoming)
*/
async getReferences(id: number) { async getReferences(id: number) {
// ดึงรายการที่เอกสารนี้ไปอ้างถึง (Outgoing: This -> Others)
const outgoing = await this.referenceRepo.find({ const outgoing = await this.referenceRepo.find({
where: { sourceId: id }, where: { sourceId: id },
relations: ['target', 'target.type'], // Join เพื่อเอาข้อมูลเอกสารปลายทาง relations: ['target', 'target.type'],
}); });
// ดึงรายการที่มาอ้างถึงเอกสารนี้ (Incoming: Others -> This)
const incoming = await this.referenceRepo.find({ const incoming = await this.referenceRepo.find({
where: { targetId: id }, where: { targetId: id },
relations: ['source', 'source.type'], // Join เพื่อเอาข้อมูลเอกสารต้นทาง relations: ['source', 'source.type'],
}); });
return { outgoing, incoming }; return { outgoing, incoming };

View File

@@ -1,3 +1,4 @@
// File: src/modules/correspondence/dto/create-correspondence.dto.ts
import { import {
IsInt, IsInt,
IsString, IsString,
@@ -16,6 +17,14 @@ export class CreateCorrespondenceDto {
@IsNotEmpty() @IsNotEmpty()
typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER) typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER)
@IsInt()
@IsOptional()
disciplineId?: number; // [Req 6B] สาขางาน (เช่น GEN, STR)
@IsInt()
@IsOptional()
subTypeId?: number; // [Req 6B] ประเภทย่อย (เช่น MAT, SHP สำหรับ Transmittal/RFA)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
title!: string; title!: string;
@@ -36,9 +45,4 @@ export class CreateCorrespondenceDto {
@IsInt() @IsInt()
@IsOptional() @IsOptional()
originatorId?: number; originatorId?: number;
// (Optional) ถ้าจะมีการแนบไฟล์มาด้วยเลย
// @IsArray()
// @IsString({ each: true })
// attachmentTempIds?: string[];
} }

View File

@@ -0,0 +1,44 @@
// File: src/modules/master/entities/correspondence-sub-type.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Contract } from '../../project/entities/contract.entity'; // ปรับ path ตามจริง
import { CorrespondenceType } from './correspondence-type.entity'; // ปรับ path ตามจริง
@Entity('correspondence_sub_types')
export class CorrespondenceSubType {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'contract_id' })
contractId!: number;
@Column({ name: 'correspondence_type_id' })
correspondenceTypeId!: number;
@Column({ name: 'sub_type_code', length: 20 })
subTypeCode!: string; // เช่น MAT, SHP
@Column({ name: 'sub_type_name', nullable: true })
subTypeName?: string;
@Column({ name: 'sub_type_number', length: 10, nullable: true })
subTypeNumber?: string; // เลขรหัสสำหรับ Running Number (เช่น 11, 22)
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// Relations
@ManyToOne(() => Contract)
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
correspondenceType?: CorrespondenceType;
}

View File

@@ -1,14 +1,33 @@
// File: src/modules/document-numbering/document-numbering.module.ts
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { DocumentNumberingService } from './document-numbering.service.js'; import { ConfigModule } from '@nestjs/config';
import { DocumentNumberFormat } from './entities/document-number-format.entity.js';
import { DocumentNumberCounter } from './entities/document-number-counter.entity.js'; import { DocumentNumberingService } from './document-numbering.service';
import { DocumentNumberFormat } from './entities/document-number-format.entity';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
// Master Entities ที่ต้องใช้ Lookup
import { Project } from '../project/entities/project.entity';
import { Organization } from '../project/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter]), ConfigModule,
TypeOrmModule.forFeature([
DocumentNumberFormat,
DocumentNumberCounter,
Project,
Organization,
CorrespondenceType,
Discipline,
CorrespondenceSubType,
]),
], ],
providers: [DocumentNumberingService], providers: [DocumentNumberingService],
exports: [DocumentNumberingService], // Export ให้คนอื่นเรียกใช้ exports: [DocumentNumberingService],
}) })
export class DocumentNumberingModule {} export class DocumentNumberingModule {}

View File

@@ -1,17 +1,36 @@
// File: src/modules/document-numbering/document-numbering.service.ts
import { import {
Injectable, Injectable,
OnModuleInit, OnModuleInit,
OnModuleDestroy, OnModuleDestroy,
InternalServerErrorException, InternalServerErrorException,
NotFoundException,
Logger, Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, OptimisticLockVersionMismatchError } from 'typeorm'; import {
Repository,
EntityManager,
OptimisticLockVersionMismatchError,
} from 'typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis'; import Redis from 'ioredis';
import Redlock from 'redlock'; import Redlock from 'redlock';
import { DocumentNumberCounter } from './entities/document-number-counter.entity.js';
import { DocumentNumberFormat } from './entities/document-number-format.entity.js'; // Entities
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberFormat } from './entities/document-number-format.entity';
import { Project } from '../project/entities/project.entity'; // สมมติ path
import { Organization } from '../project/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { Discipline } from '../master/entities/discipline.entity';
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
// Interfaces
import {
GenerateNumberContext,
DecodedTokens,
} from './interfaces/document-numbering.interface.js';
@Injectable() @Injectable()
export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
@@ -24,25 +43,39 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
private counterRepo: Repository<DocumentNumberCounter>, private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat) @InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>, private formatRepo: Repository<DocumentNumberFormat>,
// Inject Repositories สำหรับดึง Code มาทำ Token Replacement
@InjectRepository(Project) private projectRepo: Repository<Project>,
@InjectRepository(Organization) private orgRepo: Repository<Organization>,
@InjectRepository(CorrespondenceType)
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(Discipline)
private disciplineRepo: Repository<Discipline>,
@InjectRepository(CorrespondenceSubType)
private subTypeRepo: Repository<CorrespondenceSubType>,
private configService: ConfigService, private configService: ConfigService,
) {} ) {}
// 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด
onModuleInit() { onModuleInit() {
this.redisClient = new Redis({ // 1. Setup Redis Connection & Redlock
host: this.configService.get<string>('REDIS_HOST'), const host = this.configService.get<string>('REDIS_HOST', 'localhost');
port: this.configService.get<number>('REDIS_PORT'), const port = this.configService.get<number>('REDIS_PORT', 6379);
password: this.configService.get<string>('REDIS_PASSWORD'), const password = this.configService.get<string>('REDIS_PASSWORD');
});
this.redisClient = new Redis({ host, port, password });
// Config Redlock สำหรับ Distributed Lock
this.redlock = new Redlock([this.redisClient], { this.redlock = new Redlock([this.redisClient], {
driftFactor: 0.01, driftFactor: 0.01,
retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ retryCount: 10, // Retry 10 ครั้ง
retryDelay: 200, // รอ 200ms ่อนลองใหม่ retryDelay: 200, // รอ 200ms ่อครั้ง
retryJitter: 200, retryJitter: 200,
}); });
this.logger.log('Redis & Redlock initialized for Document Numbering'); this.logger.log(
`Document Numbering Service initialized (Redis: ${host}:${port})`,
);
} }
onModuleDestroy() { onModuleDestroy() {
@@ -50,115 +83,192 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
} }
/** /**
* ฟังก์ชันหลักสำหรับขอเลขที่เอกสารถัดไป * สร้างเลขที่เอกสารใหม่ (Thread-Safe & Gap-free)
* @param projectId ID โครงการ
* @param orgId ID องค์กรผู้ส่ง
* @param typeId ID ประเภทเอกสาร
* @param year ปีปัจจุบัน (ค.ศ.)
* @param replacements ค่าที่จะเอาไปแทนที่ใน Template (เช่น { ORG_CODE: 'TEAM' })
*/ */
async generateNextNumber( async generateNextNumber(ctx: GenerateNumberContext): Promise<string> {
projectId: number, const year = ctx.year || new Date().getFullYear();
orgId: number, const disciplineId = ctx.disciplineId || 0;
typeId: number,
year: number, // 1. ดึงข้อมูล Master Data มาเตรียมไว้ (Tokens) นอก Lock เพื่อ Performance
replacements: Record<string, string> = {}, const tokens = await this.resolveTokens(ctx, year);
): Promise<string> {
const resourceKey = `doc_num:${projectId}:${typeId}:${year}`; // 2. ดึง Format Template
const ttl = 5000; // Lock จะหมดอายุใน 5 วินาที (ป้องกัน Deadlock) const formatTemplate = await this.getFormatTemplate(
ctx.projectId,
ctx.typeId,
);
// 3. สร้าง Resource Key สำหรับ Lock (ละเอียดถึงระดับ Discipline)
// Key: doc_num:{projectId}:{typeId}:{disciplineId}:{year}
const resourceKey = `doc_num:${ctx.projectId}:${ctx.typeId}:${disciplineId}:${year}`;
const lockTtl = 5000; // 5 วินาที
let lock; let lock;
try { try {
// 🔒 Step 1: Redis Lock (Distributed Lock) // 🔒 LAYER 1: Acquire Redis Lock
// ป้องกันไม่ให้ Process อื่นเข้ามายุ่งกับ Counter ตัวนี้พร้อมกัน lock = await this.redlock.acquire([resourceKey], lockTtl);
lock = await this.redlock.acquire([resourceKey], ttl);
// 🔄 Step 2: Optimistic Locking Loop (Safety Net) // 🔄 LAYER 2: Optimistic Lock Loop
// เผื่อ Redis Lock หลุด หรือมีคนแทรกได้จริงๆ DB จะช่วยกันไว้อีกชั้น
const maxRetries = 3; const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
// 2.1 ดึง Counter ปัจจุบัน // A. ดึง Counter ปัจจุบัน
let counter = await this.counterRepo.findOne({ let counter = await this.counterRepo.findOne({
where: { projectId, originatorId: orgId, typeId, year }, where: {
projectId: ctx.projectId,
originatorId: ctx.originatorId,
typeId: ctx.typeId,
disciplineId: disciplineId,
year: year,
},
}); });
// ถ้ายังไม่มี ให้สร้างใหม่ (เริ่มที่ 0) // B. ถ้ายังไม่มี ให้เริ่มใหม่ที่ 0
if (!counter) { if (!counter) {
counter = this.counterRepo.create({ counter = this.counterRepo.create({
projectId, projectId: ctx.projectId,
originatorId: orgId, originatorId: ctx.originatorId,
typeId, typeId: ctx.typeId,
year, disciplineId: disciplineId,
year: year,
lastNumber: 0, lastNumber: 0,
}); });
} }
// 2.2 บวกเลข // C. Increment Sequence
counter.lastNumber += 1; counter.lastNumber += 1;
// 2.3 บันทึก (จุดนี้ TypeORM จะเช็ค Version ให้เอง) // D. Save (TypeORM จะเช็ค version column ตรงนี้)
await this.counterRepo.save(counter); await this.counterRepo.save(counter);
// 2.4 ถ้าบันทึกผ่าน -> สร้าง String ตาม Format // E. Format Result
return await this.formatNumber( return this.replaceTokens(formatTemplate, tokens, counter.lastNumber);
projectId,
typeId,
counter.lastNumber,
replacements,
);
} catch (err) { } catch (err) {
// ถ้า Version ชนกัน (Optimistic Lock Error) ให้วนลูปทำใหม่ // ถ้า Version ไม่ตรง (มีคนแทรกได้ในเสี้ยววินาที) ให้ Retry
if (err instanceof OptimisticLockVersionMismatchError) { if (err instanceof OptimisticLockVersionMismatchError) {
this.logger.warn( this.logger.warn(
`Optimistic Lock Hit! Retrying... (${i + 1}/${maxRetries})`, `Optimistic Lock Collision for ${resourceKey}. Retrying...`,
); );
continue; continue;
} }
throw err; // ถ้าเป็น Error อื่น ให้โยนออกไปเลย throw err;
} }
} }
throw new InternalServerErrorException( throw new InternalServerErrorException(
'Failed to generate document number after retries', 'Failed to generate document number after retries.',
); );
} catch (err) { } catch (error) {
this.logger.error('Error generating document number', err); this.logger.error(`Error generating number for ${resourceKey}`, error);
throw err; throw error;
} finally { } finally {
// 🔓 Step 3: Release Redis Lock เสมอ (ไม่ว่าจะสำเร็จหรือล้มเหลว) // 🔓 Release Lock
if (lock) { if (lock) {
await lock.release().catch(() => {}); // ignore error if lock expired await lock.release().catch(() => {});
} }
} }
} }
// Helper: แปลงเลขเป็น String ตาม Template (เช่น {ORG}-{SEQ:004}) /**
private async formatNumber( * Helper: ดึงข้อมูล Code ต่างๆ จาก ID เพื่อนำมาแทนที่ใน Template
*/
private async resolveTokens(
ctx: GenerateNumberContext,
year: number,
): Promise<DecodedTokens> {
const [project, org, type] = await Promise.all([
this.projectRepo.findOne({ where: { id: ctx.projectId } }),
this.orgRepo.findOne({ where: { id: ctx.originatorId } }),
this.typeRepo.findOne({ where: { id: ctx.typeId } }),
]);
if (!project || !org || !type) {
throw new NotFoundException('Project, Organization, or Type not found');
}
let disciplineCode = '000';
if (ctx.disciplineId) {
const discipline = await this.disciplineRepo.findOne({
where: { id: ctx.disciplineId },
});
if (discipline) disciplineCode = discipline.disciplineCode;
}
let subTypeCode = '00';
let subTypeNumber = '00';
if (ctx.subTypeId) {
const subType = await this.subTypeRepo.findOne({
where: { id: ctx.subTypeId },
});
if (subType) {
subTypeCode = subType.subTypeCode;
subTypeNumber = subType.subTypeNumber || '00';
}
}
// Convert Christian Year to Buddhist Year if needed (Req usually uses Christian, but prepared logic)
// ใน Req 6B ตัวอย่างใช้ 2568 (พ.ศ.) ดังนั้นต้องแปลง
const yearTh = (year + 543).toString();
return {
projectCode: project.projectCode,
orgCode: org.organizationCode,
typeCode: type.typeCode,
disciplineCode,
subTypeCode,
subTypeNumber,
year: yearTh,
yearShort: yearTh.slice(-2), // 68
};
}
/**
* Helper: หา Template จาก DB หรือใช้ Default
*/
private async getFormatTemplate(
projectId: number, projectId: number,
typeId: number, typeId: number,
seq: number,
replacements: Record<string, string>,
): Promise<string> { ): Promise<string> {
// 1. หา Template
const format = await this.formatRepo.findOne({ const format = await this.formatRepo.findOne({
where: { projectId, correspondenceTypeId: typeId }, where: { projectId, correspondenceTypeId: typeId },
}); });
// Default Fallback Format (ตาม Req 2.1)
// ถ้าไม่มี Template ให้ใช้ Default: {SEQ} return format ? format.formatTemplate : '{ORG}-{ORG}-{SEQ:4}-{YEAR}';
let template = format ? format.formatTemplate : '{SEQ:4}';
// 2. แทนที่ค่าต่างๆ (ORG_CODE, TYPE_CODE, YEAR)
for (const [key, value] of Object.entries(replacements)) {
template = template.replace(new RegExp(`{${key}}`, 'g'), value);
} }
// 3. แทนที่ SEQ (รองรับรูปแบบ {SEQ:4} คือเติม 0 ข้างหน้าให้ครบ 4 หลัก) /**
template = template.replace(/{SEQ(?::(\d+))?}/g, (_, digits) => { * Helper: แทนที่ Token ใน Template ด้วยค่าจริง
const pad = digits ? parseInt(digits, 10) : 0; */
return seq.toString().padStart(pad, '0'); private replaceTokens(
template: string,
tokens: DecodedTokens,
seq: number,
): string {
let result = template;
const replacements: Record<string, string> = {
'{PROJECT}': tokens.projectCode,
'{ORG}': tokens.orgCode,
'{TYPE}': tokens.typeCode,
'{DISCIPLINE}': tokens.disciplineCode,
'{SUBTYPE}': tokens.subTypeCode,
'{SUBTYPE_NUM}': tokens.subTypeNumber, // [Req 6B] For Transmittal/RFA
'{YEAR}': tokens.year,
'{YEAR_SHORT}': tokens.yearShort,
};
// 1. Replace Standard Tokens
for (const [key, value] of Object.entries(replacements)) {
// ใช้ Global Replace
result = result.split(key).join(value);
}
// 2. Replace Sequence Token {SEQ:n} e.g., {SEQ:4} -> 0001
result = result.replace(/{SEQ(?::(\d+))?}/g, (_, digits) => {
const padLength = digits ? parseInt(digits, 10) : 4; // Default padding 4
return seq.toString().padStart(padLength, '0');
}); });
return template; return result;
} }
} }

View File

@@ -1,8 +1,10 @@
// File: src/modules/document-numbering/entities/document-number-counter.entity.ts
import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm'; import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm';
@Entity('document_number_counters') @Entity('document_number_counters')
export class DocumentNumberCounter { export class DocumentNumberCounter {
// Composite Primary Key (Project + Org + Type + Year) // Composite Primary Key: Project + Org + Type + Discipline + Year
@PrimaryColumn({ name: 'project_id' }) @PrimaryColumn({ name: 'project_id' })
projectId!: number; projectId!: number;
@@ -12,13 +14,18 @@ export class DocumentNumberCounter {
@PrimaryColumn({ name: 'correspondence_type_id' }) @PrimaryColumn({ name: 'correspondence_type_id' })
typeId!: number; typeId!: number;
// [New v1.4.4] เพิ่ม Discipline ใน Key เพื่อแยก Counter ตามสาขา
// ใช้ default 0 กรณีไม่มี discipline เพื่อความง่ายในการจัดการ Composite Key
@PrimaryColumn({ name: 'discipline_id', default: 0 })
disciplineId!: number;
@PrimaryColumn({ name: 'current_year' }) @PrimaryColumn({ name: 'current_year' })
year!: number; year!: number;
@Column({ name: 'last_number', default: 0 }) @Column({ name: 'last_number', default: 0 })
lastNumber!: number; lastNumber!: number;
// ✨ หัวใจสำคัญของ Optimistic Lock // ✨ หัวใจสำคัญของ Optimistic Lock (TypeORM จะเช็ค version นี้ก่อน update)
@VersionColumn() @VersionColumn()
version!: number; version!: number;
} }

View File

@@ -0,0 +1,24 @@
// File: src/modules/document-numbering/interfaces/document-numbering.interface.ts
export interface GenerateNumberContext {
projectId: number;
originatorId: number; // องค์กรผู้ส่ง
typeId: number; // ประเภทเอกสาร (Correspondence Type ID)
subTypeId?: number; // (Optional) Sub Type ID (สำหรับ RFA/Transmittal)
disciplineId?: number; // (Optional) Discipline ID (สาขางาน)
year?: number; // (Optional) ถ้าไม่ส่งจะใช้ปีปัจจุบัน
// สำหรับกรณีพิเศษที่ต้องการ Override ค่าบางอย่าง
customTokens?: Record<string, string>;
}
export interface DecodedTokens {
projectCode: string;
orgCode: string;
typeCode: string;
disciplineCode: string;
subTypeCode: string;
subTypeNumber: string;
year: string;
yearShort: string;
}

View File

@@ -0,0 +1,45 @@
// File: src/modules/master/entities/discipline.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { Contract } from '../../project/entities/contract.entity'; // ปรับ path ตามจริง
@Entity('disciplines')
@Unique(['contractId', 'disciplineCode']) // ป้องกันรหัสซ้ำในสัญญาเดียวกัน
export class Discipline {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'contract_id' })
contractId!: number;
@Column({ name: 'discipline_code', length: 10 })
disciplineCode!: string; // เช่น GEN, STR, ARC
@Column({ name: 'code_name_th', nullable: true })
codeNameTh?: string;
@Column({ name: 'code_name_en', nullable: true })
codeNameEn?: string;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// Relations
@ManyToOne(() => Contract)
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
}

View File

@@ -1,3 +1,4 @@
// File: src/modules/rfa/dto/create-rfa.dto.ts
import { import {
IsInt, IsInt,
IsString, IsString,
@@ -16,6 +17,10 @@ export class CreateRfaDto {
@IsNotEmpty() @IsNotEmpty()
rfaTypeId!: number; rfaTypeId!: number;
@IsInt()
@IsOptional()
disciplineId?: number; // [Req 6B] สาขางาน (จำเป็นสำหรับการรันเลข RFA)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
title!: string; title!: string;

View File

@@ -1,3 +1,5 @@
// File: src/modules/rfa/rfa.service.ts
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -10,31 +12,31 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm'; import { Repository, DataSource, In } from 'typeorm';
// Entities // Entities
import { Rfa } from './entities/rfa.entity'; import { Rfa } from './entities/rfa.entity.js';
import { RfaRevision } from './entities/rfa-revision.entity'; import { RfaRevision } from './entities/rfa-revision.entity.js';
import { RfaItem } from './entities/rfa-item.entity'; import { RfaItem } from './entities/rfa-item.entity.js';
import { RfaType } from './entities/rfa-type.entity'; import { RfaType } from './entities/rfa-type.entity.js';
import { RfaStatusCode } from './entities/rfa-status-code.entity'; import { RfaStatusCode } from './entities/rfa-status-code.entity.js';
import { RfaApproveCode } from './entities/rfa-approve-code.entity'; import { RfaApproveCode } from './entities/rfa-approve-code.entity.js';
import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity.js';
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity'; import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity.js';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity'; import { RoutingTemplate } from '../correspondence/entities/routing-template.entity.js';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity'; import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity.js';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity.js';
// DTOs // DTOs
import { CreateRfaDto } from './dto/create-rfa.dto'; import { CreateRfaDto } from './dto/create-rfa.dto.js';
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto.js';
// Interfaces & Enums // Interfaces & Enums
import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface'; // ตรวจสอบ path นี้ให้ตรงกับไฟล์จริง import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface.js';
// Services // Services
import { DocumentNumberingService } from '../document-numbering/document-numbering.service'; import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service.js';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js';
import { NotificationService } from '../notification/notification.service'; import { NotificationService } from '../notification/notification.service.js';
import { SearchService } from '../search/search.service'; // Import SearchService import { SearchService } from '../search/search.service.js';
@Injectable() @Injectable()
export class RfaService { export class RfaService {
@@ -67,12 +69,9 @@ export class RfaService {
private workflowEngine: WorkflowEngineService, private workflowEngine: WorkflowEngineService,
private notificationService: NotificationService, private notificationService: NotificationService,
private dataSource: DataSource, private dataSource: DataSource,
private searchService: SearchService, // Inject private searchService: SearchService,
) {} ) {}
/**
* สร้างเอกสาร RFA ใหม่ (Create RFA)
*/
async create(createDto: CreateRfaDto, user: User) { async create(createDto: CreateRfaDto, user: User) {
const rfaType = await this.rfaTypeRepo.findOne({ const rfaType = await this.rfaTypeRepo.findOne({
where: { id: createDto.rfaTypeId }, where: { id: createDto.rfaTypeId },
@@ -103,20 +102,24 @@ export class RfaService {
try { try {
const orgCode = 'ORG'; // TODO: Fetch real ORG Code const orgCode = 'ORG'; // TODO: Fetch real ORG Code
const docNumber = await this.numberingService.generateNextNumber(
createDto.projectId, // [FIXED] เรียกใช้แบบ Object Context พร้อม disciplineId
userOrgId, const docNumber = await this.numberingService.generateNextNumber({
createDto.rfaTypeId, projectId: createDto.projectId,
new Date().getFullYear(), originatorId: userOrgId,
{ typeId: createDto.rfaTypeId, // RFA Type ใช้เป็น ID ในการนับเลข
disciplineId: createDto.disciplineId, // สำคัญมากสำหรับ RFA (Req 6B)
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: rfaType.typeCode, TYPE_CODE: rfaType.typeCode,
ORG_CODE: orgCode, ORG_CODE: orgCode,
}, },
); });
const correspondence = queryRunner.manager.create(Correspondence, { const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber, correspondenceNumber: docNumber,
correspondenceTypeId: createDto.rfaTypeId, correspondenceTypeId: createDto.rfaTypeId, // Map RFA Type to Corr Type ID
disciplineId: createDto.disciplineId, // บันทึก Discipline
projectId: createDto.projectId, projectId: createDto.projectId,
originatorId: userOrgId, originatorId: userOrgId,
isInternal: false, isInternal: false,
@@ -126,6 +129,7 @@ export class RfaService {
const rfa = queryRunner.manager.create(Rfa, { const rfa = queryRunner.manager.create(Rfa, {
rfaTypeId: createDto.rfaTypeId, rfaTypeId: createDto.rfaTypeId,
disciplineId: createDto.disciplineId, // บันทึก Discipline
createdBy: user.user_id, createdBy: user.user_id,
}); });
const savedRfa = await queryRunner.manager.save(rfa); const savedRfa = await queryRunner.manager.save(rfa);
@@ -168,7 +172,7 @@ export class RfaService {
} }
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
// 🔥 Fire & Forget: ไม่ต้อง await ผลลัพธ์เพื่อความเร็ว (หรือใช้ Queue ก็ได้)
this.searchService.indexDocument({ this.searchService.indexDocument({
id: savedCorr.id, id: savedCorr.id,
type: 'correspondence', type: 'correspondence',
@@ -196,9 +200,7 @@ export class RfaService {
} }
} }
/** // ... (method อื่นๆ findOne, submit, processAction คงเดิม)
* ดึงข้อมูล RFA รายตัว (Get One)
*/
async findOne(id: number) { async findOne(id: number) {
const rfa = await this.rfaRepo.findOne({ const rfa = await this.rfaRepo.findOne({
where: { id }, where: { id },
@@ -224,9 +226,6 @@ export class RfaService {
return rfa; return rfa;
} }
/**
* เริ่มต้นกระบวนการอนุมัติ (Submit Workflow)
*/
async submit(rfaId: number, templateId: number, user: User) { async submit(rfaId: number, templateId: number, user: User) {
const rfa = await this.findOne(rfaId); const rfa = await this.findOne(rfaId);
const currentRevision = rfa.revisions.find((r) => r.isCurrent); const currentRevision = rfa.revisions.find((r) => r.isCurrent);
@@ -287,7 +286,6 @@ export class RfaService {
}); });
await queryRunner.manager.save(routing); await queryRunner.manager.save(routing);
// Notification
const recipientUserId = await this.userService.findDocControlIdByOrg( const recipientUserId = await this.userService.findDocControlIdByOrg(
firstStep.toOrganizationId, firstStep.toOrganizationId,
); );
@@ -316,9 +314,6 @@ export class RfaService {
} }
} }
/**
* ดำเนินการอนุมัติ/ปฏิเสธ (Process Workflow Action)
*/
async processAction(rfaId: number, dto: WorkflowActionDto, user: User) { async processAction(rfaId: number, dto: WorkflowActionDto, user: User) {
const rfa = await this.findOne(rfaId); const rfa = await this.findOne(rfaId);
const currentRevision = rfa.revisions.find((r) => r.isCurrent); const currentRevision = rfa.revisions.find((r) => r.isCurrent);
@@ -401,7 +396,6 @@ export class RfaService {
result.nextStepSequence === null && result.nextStepSequence === null &&
dto.action !== WorkflowAction.REJECT dto.action !== WorkflowAction.REJECT
) { ) {
// Completed (Approved)
const approveCodeStr = const approveCodeStr =
dto.action === WorkflowAction.APPROVE ? '1A' : '4X'; dto.action === WorkflowAction.APPROVE ? '1A' : '4X';
const approveCode = await this.rfaApproveRepo.findOne({ const approveCode = await this.rfaApproveRepo.findOne({
@@ -414,7 +408,6 @@ export class RfaService {
} }
await queryRunner.manager.save(currentRevision); await queryRunner.manager.save(currentRevision);
} else if (dto.action === WorkflowAction.REJECT) { } else if (dto.action === WorkflowAction.REJECT) {
// Rejected
const rejectCode = await this.rfaApproveRepo.findOne({ const rejectCode = await this.rfaApproveRepo.findOne({
where: { approveCode: '4X' }, where: { approveCode: '4X' },
}); });

View File

@@ -1,3 +1,5 @@
// File: src/modules/transmittal/transmittal.service.ts
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -6,13 +8,13 @@ import {
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm'; import { Repository, DataSource, In } from 'typeorm';
import { Transmittal } from './entities/transmittal.entity'; import { Transmittal } from './entities/transmittal.entity.js';
import { TransmittalItem } from './entities/transmittal-item.entity'; import { TransmittalItem } from './entities/transmittal-item.entity.js';
import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity.js';
import { CreateTransmittalDto } from './dto/create-transmittal.dto'; // ต้องสร้าง DTO import { CreateTransmittalDto } from './dto/create-transmittal.dto.js';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity.js';
import { DocumentNumberingService } from '../document-numbering/document-numbering.service'; import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js';
import { SearchService } from '../search/search.service'; // Import SearchService import { SearchService } from '../search/search.service.js';
@Injectable() @Injectable()
export class TransmittalService { export class TransmittalService {
@@ -25,47 +27,47 @@ export class TransmittalService {
private correspondenceRepo: Repository<Correspondence>, private correspondenceRepo: Repository<Correspondence>,
private numberingService: DocumentNumberingService, private numberingService: DocumentNumberingService,
private dataSource: DataSource, private dataSource: DataSource,
private searchService: SearchService, // Inject private searchService: SearchService,
) {} ) {}
async create(createDto: CreateTransmittalDto, user: User) { async create(createDto: CreateTransmittalDto, user: User) {
// ✅ FIX: ตรวจสอบว่า User มีสังกัดองค์กรหรือไม่
if (!user.primaryOrganizationId) { if (!user.primaryOrganizationId) {
throw new BadRequestException( throw new BadRequestException(
'User must belong to an organization to create documents', 'User must belong to an organization to create documents',
); );
} }
const userOrgId = user.primaryOrganizationId; // TypeScript จะรู้ว่าเป็น number แล้ว const userOrgId = user.primaryOrganizationId;
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
// 1. Generate Document Number const transmittalTypeId = 3; // TODO: ดึง ID จริงจาก DB หรือ Config
const transmittalTypeId = 3; // TODO: ควรดึง ID จริงจาก DB หรือ Config
const orgCode = 'ORG'; // TODO: Fetch real ORG Code const orgCode = 'ORG'; // TODO: Fetch real ORG Code
const docNumber = await this.numberingService.generateNextNumber( // [FIXED] เรียกใช้แบบ Object Context
createDto.projectId, const docNumber = await this.numberingService.generateNextNumber({
userOrgId, // ✅ ส่งค่าที่เช็คแล้ว projectId: createDto.projectId,
transmittalTypeId, originatorId: userOrgId,
new Date().getFullYear(), typeId: transmittalTypeId,
{ TYPE_CODE: 'TR', ORG_CODE: orgCode }, year: new Date().getFullYear(),
); customTokens: {
TYPE_CODE: 'TR',
ORG_CODE: orgCode,
},
});
// 2. Create Correspondence (Header)
const correspondence = queryRunner.manager.create(Correspondence, { const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber, correspondenceNumber: docNumber,
correspondenceTypeId: transmittalTypeId, correspondenceTypeId: transmittalTypeId,
projectId: createDto.projectId, projectId: createDto.projectId,
originatorId: userOrgId, // ✅ ส่งค่าที่เช็คแล้ว originatorId: userOrgId,
isInternal: false, isInternal: false,
createdBy: user.user_id, createdBy: user.user_id,
}); });
const savedCorr = await queryRunner.manager.save(correspondence); const savedCorr = await queryRunner.manager.save(correspondence);
// 3. Create Transmittal (Detail)
const transmittal = queryRunner.manager.create(Transmittal, { const transmittal = queryRunner.manager.create(Transmittal, {
correspondenceId: savedCorr.id, correspondenceId: savedCorr.id,
purpose: createDto.purpose, purpose: createDto.purpose,
@@ -73,7 +75,6 @@ export class TransmittalService {
}); });
await queryRunner.manager.save(transmittal); await queryRunner.manager.save(transmittal);
// 4. Link Items (Documents being sent)
if (createDto.itemIds && createDto.itemIds.length > 0) { if (createDto.itemIds && createDto.itemIds.length > 0) {
const items = createDto.itemIds.map((itemId) => const items = createDto.itemIds.map((itemId) =>
queryRunner.manager.create(TransmittalItem, { queryRunner.manager.create(TransmittalItem, {