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

View File

@@ -1,3 +1,4 @@
// File: src/modules/correspondence/dto/create-correspondence.dto.ts
import {
IsInt,
IsString,
@@ -16,6 +17,14 @@ export class CreateCorrespondenceDto {
@IsNotEmpty()
typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER)
@IsInt()
@IsOptional()
disciplineId?: number; // [Req 6B] สาขางาน (เช่น GEN, STR)
@IsInt()
@IsOptional()
subTypeId?: number; // [Req 6B] ประเภทย่อย (เช่น MAT, SHP สำหรับ Transmittal/RFA)
@IsString()
@IsNotEmpty()
title!: string;
@@ -36,9 +45,4 @@ export class CreateCorrespondenceDto {
@IsInt()
@IsOptional()
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 { TypeOrmModule } from '@nestjs/typeorm';
import { DocumentNumberingService } from './document-numbering.service.js';
import { DocumentNumberFormat } from './entities/document-number-format.entity.js';
import { DocumentNumberCounter } from './entities/document-number-counter.entity.js';
import { ConfigModule } from '@nestjs/config';
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({
imports: [
TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter]),
ConfigModule,
TypeOrmModule.forFeature([
DocumentNumberFormat,
DocumentNumberCounter,
Project,
Organization,
CorrespondenceType,
Discipline,
CorrespondenceSubType,
]),
],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService], // Export ให้คนอื่นเรียกใช้
exports: [DocumentNumberingService],
})
export class DocumentNumberingModule {}

View File

@@ -1,17 +1,36 @@
// File: src/modules/document-numbering/document-numbering.service.ts
import {
Injectable,
OnModuleInit,
OnModuleDestroy,
InternalServerErrorException,
NotFoundException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, OptimisticLockVersionMismatchError } from 'typeorm';
import {
Repository,
EntityManager,
OptimisticLockVersionMismatchError,
} from 'typeorm';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
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()
export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
@@ -24,25 +43,39 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(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,
) {}
// 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด
onModuleInit() {
this.redisClient = new Redis({
host: this.configService.get<string>('REDIS_HOST'),
port: this.configService.get<number>('REDIS_PORT'),
password: this.configService.get<string>('REDIS_PASSWORD'),
});
// 1. Setup Redis Connection & Redlock
const host = this.configService.get<string>('REDIS_HOST', 'localhost');
const port = this.configService.get<number>('REDIS_PORT', 6379);
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], {
driftFactor: 0.01,
retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ
retryDelay: 200, // รอ 200ms ่อนลองใหม่
retryCount: 10, // Retry 10 ครั้ง
retryDelay: 200, // รอ 200ms ่อครั้ง
retryJitter: 200,
});
this.logger.log('Redis & Redlock initialized for Document Numbering');
this.logger.log(
`Document Numbering Service initialized (Redis: ${host}:${port})`,
);
}
onModuleDestroy() {
@@ -50,115 +83,192 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
}
/**
* ฟังก์ชันหลักสำหรับขอเลขที่เอกสารถัดไป
* @param projectId ID โครงการ
* @param orgId ID องค์กรผู้ส่ง
* @param typeId ID ประเภทเอกสาร
* @param year ปีปัจจุบัน (ค.ศ.)
* @param replacements ค่าที่จะเอาไปแทนที่ใน Template (เช่น { ORG_CODE: 'TEAM' })
* สร้างเลขที่เอกสารใหม่ (Thread-Safe & Gap-free)
*/
async generateNextNumber(
projectId: number,
orgId: number,
typeId: number,
year: number,
replacements: Record<string, string> = {},
): Promise<string> {
const resourceKey = `doc_num:${projectId}:${typeId}:${year}`;
const ttl = 5000; // Lock จะหมดอายุใน 5 วินาที (ป้องกัน Deadlock)
async generateNextNumber(ctx: GenerateNumberContext): Promise<string> {
const year = ctx.year || new Date().getFullYear();
const disciplineId = ctx.disciplineId || 0;
// 1. ดึงข้อมูล Master Data มาเตรียมไว้ (Tokens) นอก Lock เพื่อ Performance
const tokens = await this.resolveTokens(ctx, year);
// 2. ดึง Format Template
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;
try {
// 🔒 Step 1: Redis Lock (Distributed Lock)
// ป้องกันไม่ให้ Process อื่นเข้ามายุ่งกับ Counter ตัวนี้พร้อมกัน
lock = await this.redlock.acquire([resourceKey], ttl);
// 🔒 LAYER 1: Acquire Redis Lock
lock = await this.redlock.acquire([resourceKey], lockTtl);
// 🔄 Step 2: Optimistic Locking Loop (Safety Net)
// เผื่อ Redis Lock หลุด หรือมีคนแทรกได้จริงๆ DB จะช่วยกันไว้อีกชั้น
// 🔄 LAYER 2: Optimistic Lock Loop
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
// 2.1 ดึง Counter ปัจจุบัน
// A. ดึง Counter ปัจจุบัน
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) {
counter = this.counterRepo.create({
projectId,
originatorId: orgId,
typeId,
year,
projectId: ctx.projectId,
originatorId: ctx.originatorId,
typeId: ctx.typeId,
disciplineId: disciplineId,
year: year,
lastNumber: 0,
});
}
// 2.2 บวกเลข
// C. Increment Sequence
counter.lastNumber += 1;
// 2.3 บันทึก (จุดนี้ TypeORM จะเช็ค Version ให้เอง)
// D. Save (TypeORM จะเช็ค version column ตรงนี้)
await this.counterRepo.save(counter);
// 2.4 ถ้าบันทึกผ่าน -> สร้าง String ตาม Format
return await this.formatNumber(
projectId,
typeId,
counter.lastNumber,
replacements,
);
// E. Format Result
return this.replaceTokens(formatTemplate, tokens, counter.lastNumber);
} catch (err) {
// ถ้า Version ชนกัน (Optimistic Lock Error) ให้วนลูปทำใหม่
// ถ้า Version ไม่ตรง (มีคนแทรกได้ในเสี้ยววินาที) ให้ Retry
if (err instanceof OptimisticLockVersionMismatchError) {
this.logger.warn(
`Optimistic Lock Hit! Retrying... (${i + 1}/${maxRetries})`,
`Optimistic Lock Collision for ${resourceKey}. Retrying...`,
);
continue;
}
throw err; // ถ้าเป็น Error อื่น ให้โยนออกไปเลย
throw err;
}
}
throw new InternalServerErrorException(
'Failed to generate document number after retries',
'Failed to generate document number after retries.',
);
} catch (err) {
this.logger.error('Error generating document number', err);
throw err;
} catch (error) {
this.logger.error(`Error generating number for ${resourceKey}`, error);
throw error;
} finally {
// 🔓 Step 3: Release Redis Lock เสมอ (ไม่ว่าจะสำเร็จหรือล้มเหลว)
// 🔓 Release 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,
typeId: number,
seq: number,
replacements: Record<string, string>,
): Promise<string> {
// 1. หา Template
const format = await this.formatRepo.findOne({
where: { projectId, correspondenceTypeId: typeId },
});
// Default Fallback Format (ตาม Req 2.1)
return format ? format.formatTemplate : '{ORG}-{ORG}-{SEQ:4}-{YEAR}';
}
// ถ้าไม่มี Template ให้ใช้ Default: {SEQ}
let template = format ? format.formatTemplate : '{SEQ:4}';
/**
* Helper: แทนที่ Token ใน Template ด้วยค่าจริง
*/
private replaceTokens(
template: string,
tokens: DecodedTokens,
seq: number,
): string {
let result = template;
// 2. แทนที่ค่าต่างๆ (ORG_CODE, TYPE_CODE, YEAR)
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)) {
template = template.replace(new RegExp(`{${key}}`, 'g'), value);
// ใช้ Global Replace
result = result.split(key).join(value);
}
// 3. แทนที่ SEQ (รองรับรูปแบบ {SEQ:4} คือเติม 0 ข้างหน้าให้ครบ 4 หลัก)
template = template.replace(/{SEQ(?::(\d+))?}/g, (_, digits) => {
const pad = digits ? parseInt(digits, 10) : 0;
return seq.toString().padStart(pad, '0');
// 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';
@Entity('document_number_counters')
export class DocumentNumberCounter {
// Composite Primary Key (Project + Org + Type + Year)
// Composite Primary Key: Project + Org + Type + Discipline + Year
@PrimaryColumn({ name: 'project_id' })
projectId!: number;
@@ -12,13 +14,18 @@ export class DocumentNumberCounter {
@PrimaryColumn({ name: 'correspondence_type_id' })
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' })
year!: number;
@Column({ name: 'last_number', default: 0 })
lastNumber!: number;
// ✨ หัวใจสำคัญของ Optimistic Lock
// ✨ หัวใจสำคัญของ Optimistic Lock (TypeORM จะเช็ค version นี้ก่อน update)
@VersionColumn()
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 {
IsInt,
IsString,
@@ -16,6 +17,10 @@ export class CreateRfaDto {
@IsNotEmpty()
rfaTypeId!: number;
@IsInt()
@IsOptional()
disciplineId?: number; // [Req 6B] สาขางาน (จำเป็นสำหรับการรันเลข RFA)
@IsString()
@IsNotEmpty()
title!: string;
@@ -40,4 +45,4 @@ export class CreateRfaDto {
@IsInt({ each: true })
@IsOptional()
shopDrawingRevisionIds?: number[]; // Shop Drawings ที่แนบมา
}
}

View File

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

View File

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