251120:1700 Backend T3.4

This commit is contained in:
admin
2025-11-20 17:14:15 +07:00
parent 859475b9f0
commit 20c0f51e2a
42 changed files with 1818 additions and 10 deletions
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CorrespondenceController } from './correspondence.controller';
describe('CorrespondenceController', () => {
let controller: CorrespondenceController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CorrespondenceController],
}).compile();
controller = module.get<CorrespondenceController>(CorrespondenceController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,31 @@
import {
Controller,
Get,
Post,
Body,
UseGuards,
Request,
} from '@nestjs/common';
import { CorrespondenceService } from './correspondence.service.js';
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js';
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js';
import { RbacGuard } from '../../common/auth/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
@Controller('correspondences')
@UseGuards(JwtAuthGuard, RbacGuard)
export class CorrespondenceController {
constructor(private readonly correspondenceService: CorrespondenceService) {}
@Post()
@RequirePermission('correspondence.create') // 🔒 ต้องมีสิทธิ์สร้าง
create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) {
return this.correspondenceService.create(createDto, req.user);
}
@Get()
@RequirePermission('document.view') // 🔒 ต้องมีสิทธิ์ดู
findAll() {
return this.correspondenceService.findAll();
}
}
@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CorrespondenceService } from './correspondence.service.js';
import { CorrespondenceController } from './correspondence.controller.js';
import { Correspondence } from './entities/correspondence.entity.js';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity.js';
import { CorrespondenceType } from './entities/correspondence-type.entity.js';
// Import Entities ใหม่
import { RoutingTemplate } from './entities/routing-template.entity.js';
import { RoutingTemplateStep } from './entities/routing-template-step.entity.js';
import { CorrespondenceRouting } from './entities/correspondence-routing.entity.js';
import { CorrespondenceStatus } from './entities/correspondence-status.entity.js';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module.js'; // ต้องใช้ตอน Create
import { JsonSchemaModule } from '../json-schema/json-schema.module.js'; // ต้องใช้ Validate Details
import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module.js'; // <--- ✅ เพิ่มบรรทัดนี้ครับ
@Module({
imports: [
TypeOrmModule.forFeature([
Correspondence,
CorrespondenceRevision,
CorrespondenceType,
CorrespondenceStatus,
RoutingTemplate, // <--- ลงทะเบียน
RoutingTemplateStep, // <--- ลงทะเบียน
CorrespondenceRouting, // <--- ลงทะเบียน
]),
DocumentNumberingModule, // Import เพื่อขอเลขที่เอกสาร
JsonSchemaModule, // Import เพื่อ Validate JSON
UserModule, // <--- 2. ใส่ UserModule ใน imports เพื่อให้ RbacGuard ทำงานได้
WorkflowEngineModule, // <--- Import WorkflowEngine
],
controllers: [CorrespondenceController],
providers: [CorrespondenceService],
exports: [CorrespondenceService],
})
export class CorrespondenceModule {}
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CorrespondenceService } from './correspondence.service';
describe('CorrespondenceService', () => {
let service: CorrespondenceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CorrespondenceService],
}).compile();
service = module.get<CorrespondenceService>(CorrespondenceService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,250 @@
import {
Injectable,
NotFoundException,
BadRequestException,
InternalServerErrorException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
// Entities
import { Correspondence } from './entities/correspondence.entity.js';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity.js';
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 { User } from '../user/entities/user.entity.js';
// DTOs
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto.js';
// Services
import { DocumentNumberingService } from '../document-numbering/document-numbering.service.js';
import { JsonSchemaService } from '../json-schema/json-schema.service.js';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service.js';
@Injectable()
export class CorrespondenceService {
constructor(
@InjectRepository(Correspondence)
private correspondenceRepo: Repository<Correspondence>,
@InjectRepository(CorrespondenceRevision)
private revisionRepo: Repository<CorrespondenceRevision>,
@InjectRepository(CorrespondenceType)
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
private statusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(RoutingTemplate)
private templateRepo: Repository<RoutingTemplate>,
@InjectRepository(CorrespondenceRouting)
private routingRepo: Repository<CorrespondenceRouting>,
private numberingService: DocumentNumberingService,
private jsonSchemaService: JsonSchemaService,
private workflowEngine: WorkflowEngineService,
private dataSource: DataSource,
) {}
/**
* สร้างเอกสารใหม่ (Create Correspondence)
* - ตรวจสอบสิทธิ์และข้อมูลพื้นฐาน
* - Validate JSON Details ตาม Type
* - ขอเลขที่เอกสาร (Redis Lock)
* - บันทึกข้อมูลลง DB (Transaction)
*/
async create(createDto: CreateCorrespondenceDto, user: User) {
// 1. ตรวจสอบข้อมูลพื้นฐาน (Type, Status, Org)
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
if (!type) throw new NotFoundException('Document Type not found');
const statusDraft = await this.statusRepo.findOne({
where: { statusCode: 'DRAFT' },
});
if (!statusDraft) {
throw new InternalServerErrorException(
'Status DRAFT not found in Master Data',
);
}
const userOrgId = user.primaryOrganizationId;
if (!userOrgId) {
throw new BadRequestException(
'User must belong to an organization to create documents',
);
}
// 2. Validate JSON Details (ถ้ามี)
if (createDto.details) {
try {
// ใช้ Type Code เป็น Key ในการค้นหา Schema (เช่น 'RFA', 'LETTER')
await this.jsonSchemaService.validate(type.typeCode, createDto.details);
} catch (error: any) {
// บันทึก Warning หรือ Throw Error ตามนโยบาย (ในที่นี้ให้ผ่านไปก่อนถ้ายังไม่สร้าง Schema)
console.warn(
`Schema validation warning for ${type.typeCode}: ${error.message}`,
);
}
}
// 3. เริ่ม Transaction
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 3.1 ขอเลขที่เอกสาร (Double-Lock Mechanism)
// Mock ค่า replacements ไว้ก่อน (จริงๆ ต้อง Join เอา Org Code มา)
const docNumber = await this.numberingService.generateNextNumber(
createDto.projectId,
userOrgId,
createDto.typeId,
new Date().getFullYear(),
{
TYPE_CODE: type.typeCode,
ORG_CODE: 'ORG', // TODO: Fetch real organization code
},
);
// 3.2 สร้าง Correspondence (หัวจดหมาย)
const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber,
correspondenceTypeId: createDto.typeId,
projectId: createDto.projectId,
originatorId: userOrgId,
isInternal: createDto.isInternal || false,
createdBy: user.user_id,
});
const savedCorr = await queryRunner.manager.save(correspondence);
// 3.3 สร้าง Revision แรก (Rev 0)
const revision = queryRunner.manager.create(CorrespondenceRevision, {
correspondenceId: savedCorr.id,
revisionNumber: 0,
revisionLabel: 'A',
isCurrent: true,
statusId: statusDraft.id,
title: createDto.title,
details: createDto.details,
createdBy: user.user_id,
});
await queryRunner.manager.save(revision);
// 4. Commit Transaction
await queryRunner.commitTransaction();
return {
...savedCorr,
currentRevision: revision,
};
} catch (err) {
// Rollback หากเกิดข้อผิดพลาด
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
/**
* ดึงข้อมูลเอกสารทั้งหมด (สำหรับ List Page)
*/
async findAll() {
return this.correspondenceRepo.find({
relations: ['revisions', 'type', 'project', 'originator'],
order: { createdAt: 'DESC' },
});
}
/**
* ดึงข้อมูลเอกสารรายตัว (Detail Page)
*/
async findOne(id: number) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id },
relations: ['revisions', 'type', 'project', 'originator'],
});
if (!correspondence) {
throw new NotFoundException(`Correspondence with ID ${id} not found`);
}
return correspondence;
}
/**
* ส่งเอกสาร (Submit) เพื่อเริ่ม Workflow การอนุมัติ/ส่งต่อ
*/
async submit(correspondenceId: number, templateId: number, user: User) {
// 1. ดึงข้อมูลเอกสารและหา Revision ปัจจุบัน
const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId },
relations: ['revisions'],
});
if (!correspondence) {
throw new NotFoundException('Correspondence not found');
}
// หา Revision ที่เป็น current
const currentRevision = correspondence.revisions?.find((r) => r.isCurrent);
if (!currentRevision) {
throw new NotFoundException('Current revision not found');
}
// 2. ดึงข้อมูล Template และ Steps
const template = await this.templateRepo.findOne({
where: { id: templateId },
relations: ['steps'],
order: { steps: { sequence: 'ASC' } },
});
if (!template || !template.steps?.length) {
throw new BadRequestException(
'Invalid routing template or no steps defined',
);
}
// 3. เริ่ม Transaction
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const firstStep = template.steps[0];
// 3.1 สร้าง Routing Record แรก (Log การส่งต่อ)
const routing = queryRunner.manager.create(CorrespondenceRouting, {
correspondenceId: currentRevision.id, // เชื่อมกับ Revision ID
sequence: 1,
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, // ผู้ส่ง (User ปัจจุบัน)
processedAt: new Date(),
});
await queryRunner.manager.save(routing);
// 3.2 (Optional) อัปเดตสถานะของ Revision เป็น 'SUBMITTED'
// const statusSubmitted = await this.statusRepo.findOne({ where: { statusCode: 'SUBMITTED' } });
// if (statusSubmitted) {
// currentRevision.statusId = statusSubmitted.id;
// await queryRunner.manager.save(currentRevision);
// }
await queryRunner.commitTransaction();
return routing;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
}
@@ -0,0 +1,35 @@
import {
IsInt,
IsString,
IsNotEmpty,
IsOptional,
IsBoolean,
IsObject,
} from 'class-validator';
export class CreateCorrespondenceDto {
@IsInt()
@IsNotEmpty()
projectId!: number;
@IsInt()
@IsNotEmpty()
typeId!: number; // ID ของประเภทเอกสาร (เช่น RFA, LETTER)
@IsString()
@IsNotEmpty()
title!: string;
@IsObject()
@IsOptional()
details?: Record<string, any>; // ข้อมูล JSON (เช่น RFI question)
@IsBoolean()
@IsOptional()
isInternal?: boolean;
// (Optional) ถ้าจะมีการแนบไฟล์มาด้วยเลย
// @IsArray()
// @IsString({ each: true })
// attachmentTempIds?: string[];
}
@@ -0,0 +1,75 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Correspondence } from './correspondence.entity.js';
import { CorrespondenceStatus } from './correspondence-status.entity.js';
import { User } from '../../user/entities/user.entity.js';
@Entity('correspondence_revisions')
export class CorrespondenceRevision {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'correspondence_id' })
correspondenceId!: number;
@Column({ name: 'revision_number' })
revisionNumber!: number; // 0, 1, 2...
@Column({ name: 'revision_label', nullable: true, length: 10 })
revisionLabel?: string; // A, B, 001...
@Column({ name: 'is_current', default: false })
isCurrent!: boolean;
@Column({ name: 'correspondence_status_id' })
statusId!: number;
@Column({ length: 255 })
title!: string;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string;
@Column({ type: 'json', nullable: true })
details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type
// Dates
@Column({ name: 'document_date', type: 'date', nullable: true })
documentDate?: Date;
@Column({ name: 'issued_date', type: 'datetime', nullable: true })
issuedDate?: Date;
@Column({ name: 'received_date', type: 'datetime', nullable: true })
receivedDate?: Date;
@Column({ name: 'due_date', type: 'datetime', nullable: true })
dueDate?: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@Column({ name: 'created_by', nullable: true })
createdBy?: number;
// Relations
@ManyToOne(() => Correspondence, (corr) => corr.revisions, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'correspondence_id' })
correspondence?: Correspondence;
@ManyToOne(() => CorrespondenceStatus)
@JoinColumn({ name: 'correspondence_status_id' })
status?: CorrespondenceStatus;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
creator?: User;
}
@@ -0,0 +1,67 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { CorrespondenceRevision } from './correspondence-revision.entity.js';
import { Organization } from '../../project/entities/organization.entity.js';
import { User } from '../../user/entities/user.entity.js';
@Entity('correspondence_routings')
export class CorrespondenceRouting {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'correspondence_id' })
correspondenceId!: number; // FK -> CorrespondenceRevision
@Column()
sequence!: number;
@Column({ name: 'from_organization_id' })
fromOrganizationId!: number;
@Column({ name: 'to_organization_id' })
toOrganizationId!: number;
@Column({ name: 'step_purpose', default: 'FOR_REVIEW' })
stepPurpose!: string;
@Column({ default: 'SENT' })
status!: string; // SENT, RECEIVED, ACTIONED, FORWARDED, REPLIED
@Column({ type: 'text', nullable: true })
comments?: string;
@Column({ name: 'due_date', type: 'datetime', nullable: true })
dueDate?: Date;
@Column({ name: 'processed_by_user_id', nullable: true })
processedByUserId?: number;
@Column({ name: 'processed_at', type: 'datetime', nullable: true })
processedAt?: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// Relations
@ManyToOne(() => CorrespondenceRevision, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'correspondence_id' })
correspondenceRevision?: CorrespondenceRevision;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'from_organization_id' })
fromOrganization?: Organization;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'to_organization_id' })
toOrganization?: Organization;
@ManyToOne(() => User)
@JoinColumn({ name: 'processed_by_user_id' })
processedBy?: User;
}
@@ -0,0 +1,19 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity('correspondence_status')
export class CorrespondenceStatus {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'status_code', unique: true, length: 50 })
statusCode!: string; // เช่น DRAFT, SUBOWN
@Column({ name: 'status_name', length: 255 })
statusName!: string;
@Column({ name: 'sort_order', default: 0 })
sortOrder!: number;
@Column({ name: 'is_active', default: true, type: 'tinyint' })
isActive!: boolean;
}
@@ -0,0 +1,19 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity('correspondence_types')
export class CorrespondenceType {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'type_code', unique: true, length: 50 })
typeCode!: string; // เช่น RFA, RFI, LETTER
@Column({ name: 'type_name', length: 255 })
typeName!: string;
@Column({ name: 'sort_order', default: 0 })
sortOrder!: number;
@Column({ name: 'is_active', default: true, type: 'tinyint' })
isActive!: boolean;
}
@@ -0,0 +1,73 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
OneToMany,
DeleteDateColumn,
CreateDateColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity.js';
import { Organization } from '../../project/entities/organization.entity.js';
import { CorrespondenceType } from './correspondence-type.entity.js';
import { User } from '../../user/entities/user.entity.js';
import { CorrespondenceRevision } from './correspondence-revision.entity.js'; // เดี๋ยวสร้าง
@Entity('correspondences')
export class Correspondence {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'correspondence_number', length: 100 })
correspondenceNumber!: string;
@Column({ name: 'correspondence_type_id' })
correspondenceTypeId!: number;
@Column({ name: 'project_id' })
projectId!: number;
@Column({ name: 'originator_id', nullable: true })
originatorId?: number;
@Column({
name: 'is_internal_communication',
default: false,
type: 'tinyint',
})
isInternal!: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@Column({ name: 'created_by', nullable: true })
createdBy?: number;
@DeleteDateColumn({ name: 'deleted_at', select: false })
deletedAt?: Date;
// Relations
@ManyToOne(() => CorrespondenceType)
@JoinColumn({ name: 'correspondence_type_id' })
type?: CorrespondenceType;
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project?: Project;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'originator_id' })
originator?: Organization;
@ManyToOne(() => User)
@JoinColumn({ name: 'created_by' })
creator?: User;
// One Correspondence has Many Revisions
@OneToMany(
() => CorrespondenceRevision,
(revision) => revision.correspondence,
)
revisions?: CorrespondenceRevision[];
}
@@ -0,0 +1,38 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { RoutingTemplate } from './routing-template.entity.js';
import { Organization } from '../../project/entities/organization.entity.js';
@Entity('correspondence_routing_template_steps')
export class RoutingTemplateStep {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'template_id' })
templateId!: number;
@Column()
sequence!: number;
@Column({ name: 'to_organization_id' })
toOrganizationId!: number;
@Column({ name: 'step_purpose', default: 'FOR_REVIEW' })
stepPurpose!: string; // FOR_APPROVAL, FOR_REVIEW
@Column({ name: 'expected_days', nullable: true })
expectedDays?: number;
@ManyToOne(() => RoutingTemplate, (t) => t.steps, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'template_id' })
template?: RoutingTemplate;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'to_organization_id' })
toOrganization?: Organization;
}
@@ -0,0 +1,27 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity.js'; // ถ้าไม่ได้ใช้ BaseEntity ก็ลบออกแล้วใส่ createdAt เอง
import { RoutingTemplateStep } from './routing-template-step.entity.js'; // เดี๋ยวสร้าง
@Entity('correspondence_routing_templates')
export class RoutingTemplate {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'template_name', length: 255 })
templateName!: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'project_id', nullable: true })
projectId?: number; // NULL = แม่แบบทั่วไป
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@Column({ type: 'json', nullable: true, name: 'workflow_config' })
workflowConfig?: any;
@OneToMany(() => RoutingTemplateStep, (step) => step.template)
steps?: RoutingTemplateStep[];
}