251209:1453 Frontend: progress nest = UAT & Bug Fixing
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-09 14:53:42 +07:00
parent 8aceced902
commit aa96cd90e3
125 changed files with 11052 additions and 785 deletions

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { AuditLogService } from './audit-log.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, RbacGuard)
export class AuditLogController {
constructor(private readonly auditLogService: AuditLogService) {}
@Get()
@RequirePermission('audit-log.view')
findAll(@Query() query: any) {
return this.auditLogService.findAll(query);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLogController } from './audit-log.controller';
import { AuditLogService } from './audit-log.service';
import { AuditLog } from '../../common/entities/audit-log.entity';
import { UserModule } from '../user/user.module';
@Module({
imports: [TypeOrmModule.forFeature([AuditLog]), UserModule],
controllers: [AuditLogController],
providers: [AuditLogService],
exports: [AuditLogService],
})
export class AuditLogModule {}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from '../../common/entities/audit-log.entity';
@Injectable()
export class AuditLogService {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepository: Repository<AuditLog>
) {}
async findAll(query: any) {
const { page = 1, limit = 20, entityName, action, userId } = query;
const skip = (page - 1) * limit;
const queryBuilder =
this.auditLogRepository.createQueryBuilder('audit_logs'); // Aliased as 'audit_logs' matching table name usually, or just 'log'
if (entityName) {
queryBuilder.andWhere('audit_logs.entityName LIKE :entityName', {
entityName: `%${entityName}%`,
});
}
if (action) {
queryBuilder.andWhere('audit_logs.action = :action', { action });
}
if (userId) {
queryBuilder.andWhere('audit_logs.userId = :userId', { userId });
}
queryBuilder.orderBy('audit_logs.createdAt', 'DESC').skip(skip).take(limit);
const [data, total] = await queryBuilder.getManyAndCount();
return {
data,
meta: {
total,
page: Number(page),
limit: Number(limit),
totalPages: Math.ceil(total / limit),
},
};
}
}

View File

@@ -23,13 +23,14 @@ export class CorrespondenceWorkflowService {
private readonly revisionRepo: Repository<CorrespondenceRevision>,
@InjectRepository(CorrespondenceStatus)
private readonly statusRepo: Repository<CorrespondenceStatus>,
private readonly dataSource: DataSource,
private readonly dataSource: DataSource
) {}
async submitWorkflow(
correspondenceId: number,
userId: number,
note?: string,
userRoles: string[], // [FIX] Added roles for DSL requirements check
note?: string
) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
@@ -44,7 +45,7 @@ export class CorrespondenceWorkflowService {
if (!revision) {
throw new NotFoundException(
`Correspondence Revision for ID ${correspondenceId} not found`,
`Correspondence Revision for ID ${correspondenceId} not found`
);
}
@@ -66,7 +67,7 @@ export class CorrespondenceWorkflowService {
this.WORKFLOW_CODE,
'correspondence_revision',
revision.id.toString(),
context,
context
);
const transitionResult = await this.workflowEngine.processTransition(
@@ -74,7 +75,7 @@ export class CorrespondenceWorkflowService {
'SUBMIT',
userId,
note || 'Initial Submission',
{},
{ roles: userRoles } // [FIX] Pass roles for DSL requirements check
);
await this.syncStatus(revision, transitionResult.nextState, queryRunner);
@@ -97,14 +98,14 @@ export class CorrespondenceWorkflowService {
async processAction(
instanceId: string,
userId: number,
dto: WorkflowTransitionDto,
dto: WorkflowTransitionDto
) {
const result = await this.workflowEngine.processTransition(
instanceId,
dto.action,
userId,
dto.comment,
dto.payload,
dto.payload
);
// ✅ FIX: Method exists now
@@ -125,7 +126,7 @@ export class CorrespondenceWorkflowService {
private async syncStatus(
revision: CorrespondenceRevision,
workflowState: string,
queryRunner?: any,
queryRunner?: any
) {
const statusMap: Record<string, string> = {
DRAFT: 'DRAFT',

View File

@@ -1,28 +1,48 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CorrespondenceController } from './correspondence.controller';
import { CorrespondenceService } from './correspondence.service';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
describe('CorrespondenceController', () => {
let controller: CorrespondenceController;
let mockCorrespondenceService: Partial<CorrespondenceService>;
let mockWorkflowService: Partial<CorrespondenceWorkflowService>;
beforeEach(async () => {
mockCorrespondenceService = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
getReferences: jest.fn(),
addReference: jest.fn(),
removeReference: jest.fn(),
};
mockWorkflowService = {
submitWorkflow: jest.fn(),
processAction: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [CorrespondenceController],
providers: [
{
provide: CorrespondenceService,
useValue: {
create: jest.fn(),
findAll: jest.fn(),
submit: jest.fn(),
processAction: jest.fn(),
getReferences: jest.fn(),
addReference: jest.fn(),
removeReference: jest.fn(),
},
useValue: mockCorrespondenceService,
},
{
provide: CorrespondenceWorkflowService,
useValue: mockWorkflowService,
},
],
}).compile();
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RbacGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<CorrespondenceController>(CorrespondenceController);
});
@@ -30,4 +50,67 @@ describe('CorrespondenceController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('findAll', () => {
it('should return correspondences', async () => {
const mockResult = [{ id: 1 }];
(mockCorrespondenceService.findAll as jest.Mock).mockResolvedValue(
mockResult
);
const result = await controller.findAll({});
expect(mockCorrespondenceService.findAll).toHaveBeenCalled();
expect(result).toEqual(mockResult);
});
});
describe('create', () => {
it('should create a correspondence', async () => {
const mockCorr = { id: 1, correspondenceNumber: 'TEST-001' };
(mockCorrespondenceService.create as jest.Mock).mockResolvedValue(
mockCorr
);
const mockReq = { user: { user_id: 1 } };
const createDto = {
projectId: 1,
typeId: 1,
title: 'Test Subject',
};
const result = await controller.create(
createDto as Parameters<typeof controller.create>[0],
mockReq as Parameters<typeof controller.create>[1]
);
expect(mockCorrespondenceService.create).toHaveBeenCalledWith(
createDto,
mockReq.user
);
});
});
describe('submit', () => {
it('should submit a correspondence to workflow', async () => {
const mockResult = { instanceId: 'inst-1', currentState: 'IN_REVIEW' };
(mockWorkflowService.submitWorkflow as jest.Mock).mockResolvedValue(
mockResult
);
const mockReq = { user: { user_id: 1 } };
const result = await controller.submit(
1,
{ note: 'Test note' },
mockReq as Parameters<typeof controller.submit>[2]
);
expect(mockWorkflowService.submitWorkflow).toHaveBeenCalledWith(
1,
1,
'Test note'
);
expect(result).toEqual(mockResult);
});
});
});

View File

@@ -17,6 +17,7 @@ import {
ApiBearerAuth,
} from '@nestjs/swagger';
import { CorrespondenceService } from './correspondence.service';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { SubmitCorrespondenceDto } from './dto/submit-correspondence.dto';
import { WorkflowActionDto } from './dto/workflow-action.dto';
@@ -33,18 +34,43 @@ import { Audit } from '../../common/decorators/audit.decorator';
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
export class CorrespondenceController {
constructor(private readonly correspondenceService: CorrespondenceService) {}
constructor(
private readonly correspondenceService: CorrespondenceService,
private readonly workflowService: CorrespondenceWorkflowService
) {}
@Post(':id/workflow/action')
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
@RequirePermission('workflow.action_review')
processAction(
@Param('id', ParseIntPipe) id: number,
@Body() actionDto: WorkflowActionDto,
@Request() req: any
@Request()
req: Request & {
user: {
user_id: number;
assignments?: Array<{ role: { roleName: string } }>;
};
}
) {
return this.correspondenceService.processAction(id, actionDto, req.user);
// Extract roles from user assignments for DSL requirements check
const userRoles =
req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || [];
// Use Unified Workflow Engine via CorrespondenceWorkflowService
if (!actionDto.instanceId) {
throw new Error('instanceId is required for workflow action');
}
return this.workflowService.processAction(
actionDto.instanceId,
req.user.user_id,
{
action: actionDto.action,
comment: actionDto.comment,
payload: { ...actionDto.payload, roles: userRoles },
}
);
}
@Post()
@@ -56,8 +82,14 @@ export class CorrespondenceController {
})
@RequirePermission('correspondence.create')
@Audit('correspondence.create', 'correspondence')
create(@Body() createDto: CreateCorrespondenceDto, @Request() req: any) {
return this.correspondenceService.create(createDto, req.user);
create(
@Body() createDto: CreateCorrespondenceDto,
@Request() req: Request & { user: unknown }
) {
return this.correspondenceService.create(
createDto,
req.user as Parameters<typeof this.correspondenceService.create>[1]
);
}
@Get()
@@ -69,25 +101,45 @@ export class CorrespondenceController {
}
@Post(':id/submit')
@ApiOperation({ summary: 'Submit correspondence to workflow' })
@ApiOperation({ summary: 'Submit correspondence to Unified Workflow Engine' })
@ApiResponse({
status: 201,
description: 'Correspondence submitted successfully.',
})
@RequirePermission('correspondence.create')
@Audit('correspondence.create', 'correspondence')
@Audit('correspondence.submit', 'correspondence')
submit(
@Param('id', ParseIntPipe) id: number,
@Body() submitDto: SubmitCorrespondenceDto,
@Request() req: any
@Request()
req: Request & {
user: {
user_id: number;
assignments?: Array<{ role: { roleName: string } }>;
};
}
) {
return this.correspondenceService.submit(
// Extract roles from user assignments
const userRoles =
req.user.assignments?.map((a) => a.role?.roleName).filter(Boolean) || [];
// Use Unified Workflow Engine - pass user roles for DSL requirements check
return this.workflowService.submitWorkflow(
id,
submitDto.templateId,
req.user
req.user.user_id,
userRoles,
submitDto.note
);
}
@Get(':id')
@ApiOperation({ summary: 'Get correspondence by ID' })
@ApiResponse({ status: 200, description: 'Return correspondence details.' })
@RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.correspondenceService.findOne(id);
}
@Get(':id/references')
@ApiOperation({ summary: 'Get referenced documents' })
@ApiResponse({

View File

@@ -1,25 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CorrespondenceController } from './correspondence.controller.js';
import { CorrespondenceService } from './correspondence.service.js';
import { CorrespondenceController } from './correspondence.controller';
import { CorrespondenceService } from './correspondence.service';
import { CorrespondenceWorkflowService } from './correspondence-workflow.service';
// Entities
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { CorrespondenceType } from './entities/correspondence-type.entity';
import { Correspondence } from './entities/correspondence.entity';
// Import Entities ใหม่
import { CorrespondenceRouting } from './entities/correspondence-routing.entity';
import { RoutingTemplateStep } from './entities/routing-template-step.entity';
import { RoutingTemplate } from './entities/routing-template.entity';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module.js'; // ต้องใช้ตอน Create
import { JsonSchemaModule } from '../json-schema/json-schema.module.js'; // ต้องใช้ Validate Details
import { SearchModule } from '../search/search.module'; // ✅ 1. เพิ่ม Import SearchModule
import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module.js'; // <--- ✅ เพิ่มบรรทัดนี้ครับ
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
// Controllers & Services
import { CorrespondenceWorkflowService } from './correspondence-workflow.service'; // Register Service นี้
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
// Dependent Modules
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { JsonSchemaModule } from '../json-schema/json-schema.module';
import { UserModule } from '../user/user.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
import { SearchModule } from '../search/search.module';
/**
* CorrespondenceModule
*
* NOTE: RoutingTemplate and RoutingTemplateStep have been deprecated.
* All workflow operations now use the Unified Workflow Engine.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -27,19 +31,16 @@ import { CorrespondenceWorkflowService } from './correspondence-workflow.service
CorrespondenceRevision,
CorrespondenceType,
CorrespondenceStatus,
RoutingTemplate, // <--- ลงทะเบียน
RoutingTemplateStep, // <--- ลงทะเบียน
CorrespondenceRouting, // <--- ลงทะเบียน
CorrespondenceReference, // <--- ลงทะเบียน
CorrespondenceReference,
]),
DocumentNumberingModule, // Import เพื่อขอเลขที่เอกสาร
JsonSchemaModule, // Import เพื่อ Validate JSON
UserModule, // <--- 2. ใส่ UserModule ใน imports เพื่อให้ RbacGuard ทำงานได้
WorkflowEngineModule, // <--- Import WorkflowEngine
SearchModule, // ✅ 2. ใส่ SearchModule ที่นี่
DocumentNumberingModule,
JsonSchemaModule,
UserModule,
WorkflowEngineModule,
SearchModule,
],
controllers: [CorrespondenceController],
providers: [CorrespondenceService, CorrespondenceWorkflowService],
exports: [CorrespondenceService],
exports: [CorrespondenceService, CorrespondenceWorkflowService],
})
export class CorrespondenceModule {}

View File

@@ -1,12 +1,111 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CorrespondenceService } from './correspondence.service';
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { CorrespondenceType } from './entities/correspondence-type.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { RoutingTemplate } from './entities/routing-template.entity';
import { CorrespondenceRouting } from './entities/correspondence-routing.entity';
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { DocumentNumberingService } from '../document-numbering/document-numbering.service';
import { JsonSchemaService } from '../json-schema/json-schema.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { UserService } from '../user/user.service';
import { SearchService } from '../search/search.service';
describe('CorrespondenceService', () => {
let service: CorrespondenceService;
const createMockRepository = () => ({
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
createQueryBuilder: jest.fn(() => ({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
getMany: jest.fn().mockResolvedValue([]),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
})),
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CorrespondenceService],
providers: [
CorrespondenceService,
{
provide: getRepositoryToken(Correspondence),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceRevision),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceType),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceStatus),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(RoutingTemplate),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceRouting),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceReference),
useValue: createMockRepository(),
},
{
provide: DocumentNumberingService,
useValue: { generateNextNumber: jest.fn() },
},
{
provide: JsonSchemaService,
useValue: { validate: jest.fn() },
},
{
provide: WorkflowEngineService,
useValue: { startWorkflow: jest.fn(), processAction: jest.fn() },
},
{
provide: UserService,
useValue: { findOne: jest.fn() },
},
{
provide: DataSource,
useValue: {
createQueryRunner: jest.fn(() => ({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: {
save: jest.fn(),
findOne: jest.fn(),
},
})),
},
},
{
provide: SearchService,
useValue: { indexDocument: jest.fn() },
},
],
}).compile();
service = module.get<CorrespondenceService>(CorrespondenceService);
@@ -15,4 +114,12 @@ describe('CorrespondenceService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should return paginated correspondences', async () => {
const result = await service.findAll({ projectId: 1 });
expect(result.data).toBeDefined();
expect(result.meta).toBeDefined();
});
});
});

View File

@@ -9,27 +9,21 @@ import {
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, Like, In } from 'typeorm';
import { Repository, DataSource } from 'typeorm';
// Entitie
// Entities
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
import { CorrespondenceType } from './entities/correspondence-type.entity';
import { CorrespondenceStatus } from './entities/correspondence-status.entity';
import { RoutingTemplate } from './entities/routing-template.entity';
import { CorrespondenceRouting } from './entities/correspondence-routing.entity';
import { CorrespondenceReference } from './entities/correspondence-reference.entity';
import { User } from '../user/entities/user.entity';
// DTOs
import { CreateCorrespondenceDto } from './dto/create-correspondence.dto';
import { WorkflowActionDto } from './dto/workflow-action.dto';
import { AddReferenceDto } from './dto/add-reference.dto';
import { SearchCorrespondenceDto } from './dto/search-correspondence.dto';
// Interfaces & Enums
import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface';
// Services
import { DocumentNumberingService } from '../document-numbering/document-numbering.service';
import { JsonSchemaService } from '../json-schema/json-schema.service';
@@ -37,6 +31,12 @@ import { WorkflowEngineService } from '../workflow-engine/workflow-engine.servic
import { UserService } from '../user/user.service';
import { SearchService } from '../search/search.service';
/**
* CorrespondenceService - Document management (CRUD)
*
* NOTE: Workflow operations (submit, processAction) have been moved to
* CorrespondenceWorkflowService which uses the Unified Workflow Engine.
*/
@Injectable()
export class CorrespondenceService {
private readonly logger = new Logger(CorrespondenceService.name);
@@ -50,10 +50,6 @@ export class CorrespondenceService {
private typeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
private statusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(RoutingTemplate)
private templateRepo: Repository<RoutingTemplate>,
@InjectRepository(CorrespondenceRouting)
private routingRepo: Repository<CorrespondenceRouting>,
@InjectRepository(CorrespondenceReference)
private referenceRepo: Repository<CorrespondenceReference>,
@@ -111,9 +107,9 @@ export class CorrespondenceService {
if (createDto.details) {
try {
await this.jsonSchemaService.validate(type.typeCode, createDto.details);
} catch (error: any) {
} catch (error: unknown) {
this.logger.warn(
`Schema validation warning for ${type.typeCode}: ${error.message}`
`Schema validation warning for ${type.typeCode}: ${(error as Error).message}`
);
}
}
@@ -125,13 +121,12 @@ export class CorrespondenceService {
try {
const orgCode = 'ORG'; // TODO: Fetch real ORG Code from Organization Entity
// [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 (ถ้ามี)
disciplineId: createDto.disciplineId,
subTypeId: createDto.subTypeId,
year: new Date().getFullYear(),
customTokens: {
TYPE_CODE: type.typeCode,
@@ -142,7 +137,7 @@ export class CorrespondenceService {
const correspondence = queryRunner.manager.create(Correspondence, {
correspondenceNumber: docNumber,
correspondenceTypeId: createDto.typeId,
disciplineId: createDto.disciplineId, // บันทึก Discipline ลง DB
disciplineId: createDto.disciplineId,
projectId: createDto.projectId,
originatorId: userOrgId,
isInternal: createDto.isInternal || false,
@@ -165,7 +160,7 @@ export class CorrespondenceService {
await queryRunner.commitTransaction();
// [NEW V1.5.1] Start Workflow Instance (After Commit)
// Start Workflow Instance (non-blocking)
try {
const workflowCode = `CORRESPONDENCE_${type.typeCode}`;
await this.workflowEngine.createInstance(
@@ -183,7 +178,6 @@ export class CorrespondenceService {
this.logger.warn(
`Workflow not started for ${docNumber} (Code: CORRESPONDENCE_${type.typeCode}): ${(error as Error).message}`
);
// Non-blocking: Document is created, but workflow might not be active.
}
this.searchService.indexDocument({
@@ -212,7 +206,6 @@ export class CorrespondenceService {
}
}
// ... (method อื่นๆ คงเดิม)
async findAll(searchDto: SearchCorrespondenceDto = {}) {
const { search, typeId, projectId, statusId } = searchDto;
@@ -266,182 +259,6 @@ export class CorrespondenceService {
return correspondence;
}
async submit(correspondenceId: number, templateId: number, user: User) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId },
relations: ['revisions'],
});
if (!correspondence) {
throw new NotFoundException('Correspondence not found');
}
const currentRevision = correspondence.revisions?.find((r) => r.isCurrent);
if (!currentRevision) {
throw new NotFoundException('Current revision not found');
}
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'
);
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const firstStep = template.steps[0];
const routing = queryRunner.manager.create(CorrespondenceRouting, {
correspondenceId: currentRevision.id,
templateId: template.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,
processedAt: new Date(),
});
await queryRunner.manager.save(routing);
await queryRunner.commitTransaction();
return routing;
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async processAction(
correspondenceId: number,
dto: WorkflowActionDto,
user: User
) {
const correspondence = await this.correspondenceRepo.findOne({
where: { id: correspondenceId },
relations: ['revisions'],
});
if (!correspondence)
throw new NotFoundException('Correspondence not found');
const currentRevision = correspondence.revisions?.find((r) => r.isCurrent);
if (!currentRevision)
throw new NotFoundException('Current revision not found');
const currentRouting = await this.routingRepo.findOne({
where: {
correspondenceId: currentRevision.id,
status: 'SENT',
},
order: { sequence: 'DESC' },
relations: ['toOrganization'],
});
if (!currentRouting) {
throw new BadRequestException(
'No active workflow step found for this document'
);
}
if (currentRouting.toOrganizationId !== user.primaryOrganizationId) {
throw new BadRequestException(
'You are not authorized to process this step'
);
}
if (!currentRouting.templateId) {
throw new InternalServerErrorException(
'Routing record missing templateId'
);
}
const template = await this.templateRepo.findOne({
where: { id: currentRouting.templateId },
relations: ['steps'],
});
if (!template || !template.steps) {
throw new InternalServerErrorException('Template definition not found');
}
const totalSteps = template.steps.length;
const currentSeq = currentRouting.sequence;
const result = this.workflowEngine.processAction(
currentSeq,
totalSteps,
dto.action,
dto.returnToSequence
);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
currentRouting.status =
dto.action === WorkflowAction.REJECT ? 'REJECTED' : 'ACTIONED';
currentRouting.processedByUserId = user.user_id;
currentRouting.processedAt = new Date();
currentRouting.comments = dto.comments;
await queryRunner.manager.save(currentRouting);
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
const nextStepConfig = template.steps.find(
(s) => s.sequence === result.nextStepSequence
);
if (!nextStepConfig) {
this.logger.warn(
`Next step ${result.nextStepSequence} not found in template`
);
} else {
const nextRouting = queryRunner.manager.create(
CorrespondenceRouting,
{
correspondenceId: currentRevision.id,
templateId: template.id,
sequence: result.nextStepSequence,
fromOrganizationId: user.primaryOrganizationId,
toOrganizationId: nextStepConfig.toOrganizationId,
stepPurpose: nextStepConfig.stepPurpose,
status: 'SENT',
dueDate: new Date(
Date.now() +
(nextStepConfig.expectedDays || 7) * 24 * 60 * 60 * 1000
),
}
);
await queryRunner.manager.save(nextRouting);
}
}
await queryRunner.commitTransaction();
return { message: 'Action processed successfully', result };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async addReference(id: number, dto: AddReferenceDto) {
const source = await this.correspondenceRepo.findOne({ where: { id } });
const target = await this.correspondenceRepo.findOne({

View File

@@ -1,12 +1,16 @@
import { IsInt, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for submitting correspondence to workflow
* Uses Unified Workflow Engine - no templateId required
*/
export class SubmitCorrespondenceDto {
@ApiProperty({
description: 'ID of the Workflow Template to start',
example: 1,
@ApiPropertyOptional({
description: 'Optional note for the submission',
example: 'Submitting for review',
})
@IsInt()
@IsNotEmpty()
templateId!: number;
@IsString()
@IsOptional()
note?: string;
}

View File

@@ -1,14 +1,29 @@
import { IsEnum, IsString, IsOptional, IsInt } from 'class-validator';
import { IsEnum, IsString, IsOptional, IsUUID, IsInt } from 'class-validator';
import { WorkflowAction } from '../../workflow-engine/interfaces/workflow.interface';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for processing workflow actions
*
* Supports both:
* - New Unified Workflow Engine (uses instanceId)
* - Legacy RFA workflow (uses returnToSequence)
*/
export class WorkflowActionDto {
@ApiPropertyOptional({
description: 'Workflow Instance ID (UUID) - for Unified Workflow Engine',
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
})
@IsUUID()
@IsOptional()
instanceId?: string;
@ApiProperty({
description: 'Workflow Action',
enum: ['APPROVE', 'REJECT', 'RETURN', 'CANCEL', 'ACKNOWLEDGE'],
})
@IsEnum(WorkflowAction)
action!: WorkflowAction; // APPROVE, REJECT, RETURN, ACKNOWLEDGE
action!: WorkflowAction;
@ApiPropertyOptional({
description: 'Review comments',
@@ -16,13 +31,31 @@ export class WorkflowActionDto {
})
@IsString()
@IsOptional()
comment?: string;
/**
* @deprecated Use 'comment' instead
*/
@ApiPropertyOptional({
description: 'Review comments (deprecated, use comment)',
example: 'Approved with note...',
})
@IsString()
@IsOptional()
comments?: string;
@ApiPropertyOptional({
description: 'Sequence to return to (only for RETURN action)',
description: 'Sequence to return to (only for RETURN action in legacy RFA)',
example: 1,
})
@IsInt()
@IsOptional()
returnToSequence?: number; // ใช้กรณี action = RETURN
returnToSequence?: number;
@ApiPropertyOptional({
description: 'Additional payload data',
example: { priority: 'HIGH' },
})
@IsOptional()
payload?: Record<string, unknown>;
}

View File

@@ -1,15 +1,12 @@
// File: src/modules/correspondence/entities/routing-template-step.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { RoutingTemplate } from './routing-template.entity';
import { Organization } from '../../project/entities/organization.entity';
import { Role } from '../../user/entities/role.entity';
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
/**
* @deprecated This entity is deprecated and will be removed in future versions.
* Use WorkflowDefinition from the Unified Workflow Engine instead.
*
* This entity is kept for backward compatibility and historical data.
* Relations have been removed to prevent TypeORM errors.
*/
@Entity('correspondence_routing_template_steps')
export class RoutingTemplateStep {
@PrimaryGeneratedColumn()
@@ -24,27 +21,12 @@ export class RoutingTemplateStep {
@Column({ name: 'to_organization_id' })
toOrganizationId!: number;
@Column({ name: 'role_id', nullable: true })
roleId?: number;
@Column({ name: 'step_purpose', default: 'FOR_REVIEW' })
@Column({ name: 'step_purpose', length: 50, default: 'FOR_REVIEW' })
stepPurpose!: string;
@Column({ name: 'expected_days', nullable: true })
expectedDays?: number;
@Column({ name: 'expected_days', default: 7 })
expectedDays!: number;
// Relations
@ManyToOne(() => RoutingTemplate, (template) => template.steps, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'template_id' })
template?: RoutingTemplate;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'to_organization_id' })
toOrganization?: Organization;
@ManyToOne(() => Role)
@JoinColumn({ name: 'role_id' })
role?: Role;
// @deprecated - Relation removed, use WorkflowDefinition instead
// template?: RoutingTemplate;
}

View File

@@ -1,7 +1,12 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity'; // ถ้าไม่ได้ใช้ BaseEntity ก็ลบออกแล้วใส่ createdAt เอง
import { RoutingTemplateStep } from './routing-template-step.entity'; // เดี๋ยวสร้าง
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
/**
* @deprecated This entity is deprecated and will be removed in future versions.
* Use WorkflowDefinition from the Unified Workflow Engine instead.
*
* This entity is kept for backward compatibility and historical data.
* The relation to RoutingTemplateStep has been removed to prevent TypeORM errors.
*/
@Entity('correspondence_routing_templates')
export class RoutingTemplate {
@PrimaryGeneratedColumn()
@@ -14,14 +19,14 @@ export class RoutingTemplate {
description?: string;
@Column({ name: 'project_id', nullable: true })
projectId?: number; // NULL = แม่แบบทั่วไป
projectId?: number;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@Column({ type: 'json', nullable: true, name: 'workflow_config' })
workflowConfig?: any;
workflowConfig?: Record<string, unknown>;
@OneToMany(() => RoutingTemplateStep, (step) => step.template)
steps?: RoutingTemplateStep[];
// @deprecated - Relation removed, use WorkflowDefinition instead
// steps?: RoutingTemplateStep[];
}

View File

@@ -0,0 +1,44 @@
import {
Controller,
Get,
UseGuards,
Query,
ParseIntPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { DocumentNumberingService } from './document-numbering.service';
@ApiTags('Document Numbering')
@ApiBearerAuth()
@Controller('document-numbering')
@UseGuards(JwtAuthGuard, RbacGuard)
export class DocumentNumberingController {
constructor(private readonly numberingService: DocumentNumberingService) {}
@Get('logs/audit')
@ApiOperation({ summary: 'Get document generation audit logs' })
@ApiResponse({ status: 200, description: 'List of audit logs' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@RequirePermission('system.view_logs')
getAuditLogs(@Query('limit') limit?: number) {
return this.numberingService.getAuditLogs(limit ? Number(limit) : 100);
}
@Get('logs/errors')
@ApiOperation({ summary: 'Get document generation error logs' })
@ApiResponse({ status: 200, description: 'List of error logs' })
@ApiQuery({ name: 'limit', required: false, type: Number })
@RequirePermission('system.view_logs')
getErrorLogs(@Query('limit') limit?: number) {
return this.numberingService.getErrorLogs(limit ? Number(limit) : 100);
}
}

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { DocumentNumberingService } from './document-numbering.service';
import { DocumentNumberingController } from './document-numbering.controller';
import { DocumentNumberFormat } from './entities/document-number-format.entity';
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
import { DocumentNumberAudit } from './entities/document-number-audit.entity'; // [P0-4]
@@ -15,10 +16,12 @@ 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';
import { UserModule } from '../user/user.module';
@Module({
imports: [
ConfigModule,
UserModule,
TypeOrmModule.forFeature([
DocumentNumberFormat,
DocumentNumberCounter,
@@ -31,6 +34,7 @@ import { CorrespondenceSubType } from '../correspondence/entities/correspondence
CorrespondenceSubType,
]),
],
controllers: [DocumentNumberingController],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService],
})

View File

@@ -117,7 +117,7 @@ describe('DocumentNumberingService', () => {
afterEach(async () => {
jest.clearAllMocks();
service.onModuleDestroy();
// Don't call onModuleDestroy - redisClient is mocked and would cause undefined error
});
it('should be defined', () => {
@@ -145,7 +145,7 @@ describe('DocumentNumberingService', () => {
const result = await service.generateNextNumber(mockContext);
expect(result).toBe('000001'); // Default padding 6
expect(result).toBe('0001'); // Default padding 4 (see replaceTokens method)
expect(counterRepo.save).toHaveBeenCalled();
expect(auditRepo.save).toHaveBeenCalled();
});

View File

@@ -118,12 +118,19 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
// A. ดึง Counter ปัจจุบัน
// A. ดึง Counter ปัจจุบัน (v1.5.1: 8-column composite PK)
const recipientId = ctx.recipientOrganizationId ?? -1; // -1 = all orgs (FK constraint removed in schema)
const subTypeId = ctx.subTypeId ?? 0;
const rfaTypeId = ctx.rfaTypeId ?? 0;
let counter = await this.counterRepo.findOne({
where: {
projectId: ctx.projectId,
originatorId: ctx.originatorId,
recipientOrganizationId: recipientId,
typeId: ctx.typeId,
subTypeId: subTypeId,
rfaTypeId: rfaTypeId,
disciplineId: disciplineId,
year: year,
},
@@ -134,7 +141,10 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
counter = this.counterRepo.create({
projectId: ctx.projectId,
originatorId: ctx.originatorId,
recipientOrganizationId: recipientId,
typeId: ctx.typeId,
subTypeId: subTypeId,
rfaTypeId: rfaTypeId,
disciplineId: disciplineId,
year: year,
lastNumber: 0,
@@ -155,16 +165,20 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
);
// [P0-4] F. Audit Logging
// NOTE: Audit creation requires documentId which is not available here.
// Skipping audit log for now or it should be handled by the caller.
/*
await this.logAudit({
generatedNumber,
counterKey: resourceKey,
counterKey: { key: resourceKey },
templateUsed: formatTemplate,
sequenceNumber: counter.lastNumber,
documentId: 0, // Placeholder
userId: ctx.userId,
ipAddress: ctx.ipAddress,
retryCount: i,
lockWaitMs: 0, // TODO: calculate actual wait time
lockWaitMs: 0,
});
*/
return generatedNumber;
} catch (err) {
@@ -185,15 +199,18 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
} catch (error: any) {
this.logger.error(`Error generating number for ${resourceKey}`, error);
const errorContext = {
...ctx,
counterKey: resourceKey,
};
// [P0-4] Log error
await this.logError({
counterKey: resourceKey,
errorType: this.classifyError(error),
context: errorContext,
errorMessage: error.message,
stackTrace: error.stack,
userId: ctx.userId,
ipAddress: ctx.ipAddress,
context: ctx,
}).catch(() => {}); // Don't throw if error logging fails
throw error;
@@ -246,11 +263,11 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
// ใน Req 6B ตัวอย่างใช้ 2568 (พ.ศ.) ดังนั้นต้องแปลง
const yearTh = (year + 543).toString();
// [P1-4] Resolve recipient organization
// [v1.5.1] Resolve recipient organization
let recipientCode = '';
if (ctx.recipientOrgId) {
if (ctx.recipientOrganizationId && ctx.recipientOrganizationId > 0) {
const recipient = await this.orgRepo.findOne({
where: { id: ctx.recipientOrgId },
where: { id: ctx.recipientOrganizationId },
});
if (recipient) {
recipientCode = recipient.organizationCode;
@@ -321,6 +338,10 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
return result;
}
/**
* [P0-4] Log successful number generation to audit table
*/
/**
* [P0-4] Log successful number generation to audit table
*/
@@ -331,7 +352,6 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
await this.auditRepo.save(auditData);
} catch (error) {
this.logger.error('Failed to log audit', error);
// Don't throw - audit failure shouldn't block number generation
}
}
@@ -366,4 +386,20 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
}
return 'VALIDATION_ERROR';
}
// --- Log Retrieval for Admin UI ---
async getAuditLogs(limit = 100): Promise<DocumentNumberAudit[]> {
return this.auditRepo.find({
order: { createdAt: 'DESC' },
take: limit,
});
}
async getErrorLogs(limit = 100): Promise<DocumentNumberError[]> {
return this.errorRepo.find({
order: { createdAt: 'DESC' },
take: limit,
});
}
}

View File

@@ -7,36 +7,50 @@ import {
} from 'typeorm';
@Entity('document_number_audit')
@Index(['generatedAt'])
@Index(['createdAt'])
@Index(['userId'])
export class DocumentNumberAudit {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'document_id' })
documentId!: number;
@Column({ name: 'generated_number', length: 100 })
generatedNumber!: string;
@Column({ name: 'counter_key', length: 255 })
counterKey!: string;
@Column({ name: 'counter_key', type: 'json' })
counterKey!: any;
@Column({ name: 'template_used', type: 'text' })
@Column({ name: 'template_used', length: 200 })
templateUsed!: string;
@Column({ name: 'sequence_number' })
sequenceNumber!: number;
@Column({ name: 'user_id', nullable: true })
userId?: number;
@Column({ name: 'user_id' })
userId!: number;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress?: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent?: string;
@Column({ name: 'retry_count', default: 0 })
retryCount!: number;
@Column({ name: 'lock_wait_ms', nullable: true })
lockWaitMs?: number;
@CreateDateColumn({ name: 'generated_at' })
generatedAt!: Date;
@Column({ name: 'total_duration_ms', nullable: true })
totalDurationMs?: number;
@Column({
name: 'fallback_used',
type: 'enum',
enum: ['NONE', 'DB_LOCK', 'RETRY'],
default: 'NONE',
})
fallbackUsed?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}

View File

@@ -3,7 +3,7 @@ import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm';
@Entity('document_number_counters')
export class DocumentNumberCounter {
// Composite Primary Key: Project + Org + Type + Discipline + Year
// Composite Primary Key: 8 columns (v1.5.1 schema)
@PrimaryColumn({ name: 'project_id' })
projectId!: number;
@@ -11,11 +11,22 @@ export class DocumentNumberCounter {
@PrimaryColumn({ name: 'originator_organization_id' })
originatorId!: number;
// [v1.5.1 NEW] -1 = all organizations (FK removed in schema for this special value)
@PrimaryColumn({ name: 'recipient_organization_id', default: -1 })
recipientOrganizationId!: number;
@PrimaryColumn({ name: 'correspondence_type_id' })
typeId!: number;
// [New v1.4.4] เพิ่ม Discipline ใน Key เพื่อแยก Counter ตามสาขา
// ใช้ default 0 กรณีไม่มี discipline เพื่อความง่ายในการจัดการ Composite Key
// [v1.5.1 NEW] Sub-type for TRANSMITTAL (0 = not specified)
@PrimaryColumn({ name: 'sub_type_id', default: 0 })
subTypeId!: number;
// [v1.5.1 NEW] RFA type: SHD, RPT, MAT (0 = not RFA)
@PrimaryColumn({ name: 'rfa_type_id', default: 0 })
rfaTypeId!: number;
// Discipline: TER, STR, GEO (0 = not specified)
@PrimaryColumn({ name: 'discipline_id', default: 0 })
disciplineId!: number;
@@ -25,7 +36,7 @@ export class DocumentNumberCounter {
@Column({ name: 'last_number', default: 0 })
lastNumber!: number;
// ✨ หัวใจสำคัญของ Optimistic Lock (TypeORM จะเช็ค version นี้ก่อน update)
// ✨ Optimistic Lock (TypeORM checks version before update)
@VersionColumn()
version!: number;
}

View File

@@ -7,33 +7,30 @@ import {
} from 'typeorm';
@Entity('document_number_errors')
@Index(['errorAt'])
@Index(['createdAt'])
@Index(['userId'])
export class DocumentNumberError {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'counter_key', length: 255 })
counterKey!: string;
@Column({ name: 'error_type', length: 50 })
errorType!: string;
@Column({ name: 'error_message', type: 'text' })
errorMessage!: string;
@Column({ name: 'stack_trace', type: 'text', nullable: true })
stackTrace?: string;
@Column({ name: 'context_data', type: 'json', nullable: true })
context?: any;
@Column({ name: 'user_id', nullable: true })
userId?: number;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress?: string;
@Column({ name: 'context', type: 'json', nullable: true })
context?: any;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@CreateDateColumn({ name: 'error_at' })
errorAt!: Date;
@Column({ name: 'resolved_at', type: 'timestamp', nullable: true })
resolvedAt?: Date;
}

View File

@@ -4,12 +4,13 @@ export interface GenerateNumberContext {
projectId: number;
originatorId: number; // องค์กรผู้ส่ง
typeId: number; // ประเภทเอกสาร (Correspondence Type ID)
subTypeId?: number; // (Optional) Sub Type ID (สำหรับ RFA/Transmittal)
subTypeId?: number; // (Optional) Sub Type ID (สำหรับ Transmittal)
rfaTypeId?: number; // [v1.5.1] RFA Type: SHD, RPT, MAT (0 = not RFA)
disciplineId?: number; // (Optional) Discipline ID (สาขางาน)
year?: number; // (Optional) ถ้าไม่ส่งจะใช้ปีปัจจุบัน
// [P1-4] Recipient organization for {RECIPIENT} token
recipientOrgId?: number; // Primary recipient organization
// [v1.5.1] Recipient organization for counter key
recipientOrganizationId?: number; // Primary recipient (-1 = all orgs)
// [P0-4] Audit tracking fields
userId?: number; // User requesting the number

View File

@@ -1,5 +1,6 @@
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Contract } from './contract.entity';
@Entity('projects')
export class Project extends BaseEntity {
@@ -14,4 +15,7 @@ export class Project extends BaseEntity {
@Column({ name: 'is_active', default: 1, type: 'tinyint' })
isActive!: boolean;
@OneToMany(() => Contract, (contract) => contract.project)
contracts!: Contract[];
}

View File

@@ -1,19 +1,67 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ProjectService } from './project.service.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { Test, TestingModule } from '@nestjs/testing';
import { ProjectController } from './project.controller';
import { ProjectService } from './project.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectController {
constructor(private readonly projectService: ProjectService) {}
describe('ProjectController', () => {
let controller: ProjectController;
let mockProjectService: Partial<ProjectService>;
@Get()
findAll() {
return this.projectService.findAllProjects();
}
beforeEach(async () => {
mockProjectService = {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
findAllOrganizations: jest.fn(),
};
@Get('organizations')
findAllOrgs() {
return this.projectService.findAllOrganizations();
}
}
const module: TestingModule = await Test.createTestingModule({
controllers: [ProjectController],
providers: [
{
provide: ProjectService,
useValue: mockProjectService,
},
],
})
// Override guards to avoid dependency issues
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RbacGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ProjectController>(ProjectController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('findAll', () => {
it('should call projectService.findAll', async () => {
const mockResult = { data: [], meta: {} };
(mockProjectService.findAll as jest.Mock).mockResolvedValue(mockResult);
const result = await controller.findAll({});
expect(mockProjectService.findAll).toHaveBeenCalled();
});
});
describe('findAllOrganizations', () => {
it('should call projectService.findAllOrganizations', async () => {
const mockOrgs = [{ organization_id: 1, name: 'Test Org' }];
(mockProjectService.findAllOrganizations as jest.Mock).mockResolvedValue(
mockOrgs
);
const result = await controller.findAllOrgs();
expect(mockProjectService.findAllOrganizations).toHaveBeenCalled();
});
});
});

View File

@@ -12,14 +12,14 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ProjectService } from './project.service.js';
import { CreateProjectDto } from './dto/create-project.dto.js';
import { UpdateProjectDto } from './dto/update-project.dto.js';
import { SearchProjectDto } from './dto/search-project.dto.js';
import { ProjectService } from './project.service';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { SearchProjectDto } from './dto/search-project.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard.js';
import { RbacGuard } from '../../common/guards/rbac.guard.js';
import { RequirePermission } from '../../common/decorators/require-permission.decorator.js';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Projects')
@ApiBearerAuth()
@@ -49,6 +49,13 @@ export class ProjectController {
return this.projectService.findAllOrganizations();
}
@Get(':id/contracts')
@ApiOperation({ summary: 'List All Contracts in Project' })
@RequirePermission('project.view')
findContracts(@Param('id', ParseIntPipe) id: number) {
return this.projectService.findContracts(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get Project Details' })
@RequirePermission('project.view')
@@ -61,7 +68,7 @@ export class ProjectController {
@RequirePermission('project.edit')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateProjectDto,
@Body() updateDto: UpdateProjectDto
) {
return this.projectService.update(id, updateDto);
}

View File

@@ -1,12 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ProjectService } from './project.service';
import { Project } from './entities/project.entity';
import { Organization } from './entities/organization.entity';
describe('ProjectService', () => {
let service: ProjectService;
let mockProjectRepository: Record<string, jest.Mock>;
let mockOrganizationRepository: Record<string, jest.Mock>;
beforeEach(async () => {
mockProjectRepository = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
createQueryBuilder: jest.fn(() => ({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
})),
};
mockOrganizationRepository = {
find: jest.fn(),
findOne: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [ProjectService],
providers: [
ProjectService,
{
provide: getRepositoryToken(Project),
useValue: mockProjectRepository,
},
{
provide: getRepositoryToken(Organization),
useValue: mockOrganizationRepository,
},
],
}).compile();
service = module.get<ProjectService>(ProjectService);
@@ -15,4 +52,36 @@ describe('ProjectService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should return paginated projects', async () => {
const mockProjects = [
{
project_id: 1,
project_code: 'PROJ-001',
project_name: 'Test Project',
},
];
mockProjectRepository
.createQueryBuilder()
.getManyAndCount.mockResolvedValue([mockProjects, 1]);
const result = await service.findAll({});
expect(result.data).toBeDefined();
expect(result.meta).toBeDefined();
});
});
describe('findAllOrganizations', () => {
it('should return all organizations', async () => {
const mockOrgs = [{ organization_id: 1, name: 'Test Org' }];
mockOrganizationRepository.find.mockResolvedValue(mockOrgs);
const result = await service.findAllOrganizations();
expect(mockOrganizationRepository.find).toHaveBeenCalled();
expect(result).toEqual(mockOrgs);
});
});
});

View File

@@ -24,7 +24,7 @@ export class ProjectService {
@InjectRepository(Project)
private projectRepository: Repository<Project>,
@InjectRepository(Organization)
private organizationRepository: Repository<Organization>,
private organizationRepository: Repository<Organization>
) {}
// --- CRUD Operations ---
@@ -36,7 +36,7 @@ export class ProjectService {
});
if (existing) {
throw new ConflictException(
`Project Code "${createDto.projectCode}" already exists`,
`Project Code "${createDto.projectCode}" already exists`
);
}
@@ -59,7 +59,7 @@ export class ProjectService {
if (search) {
query.andWhere(
'(project.projectCode LIKE :search OR project.projectName LIKE :search)',
{ search: `%${search}%` },
{ search: `%${search}%` }
);
}
@@ -107,6 +107,19 @@ export class ProjectService {
return this.projectRepository.softRemove(project);
}
async findContracts(projectId: number) {
const project = await this.projectRepository.findOne({
where: { id: projectId },
relations: ['contracts'],
});
if (!project) {
throw new NotFoundException(`Project ID ${projectId} not found`);
}
return project.contracts;
}
// --- Organization Helper ---
async findAllOrganizations() {

View File

@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
import { RfaItem } from './entities/rfa-item.entity';
@@ -45,6 +46,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
RfaWorkflowTemplateStep,
CorrespondenceRouting,
RoutingTemplate,
RoutingTemplateStep,
]),
DocumentNumberingModule,
UserModule,

View File

@@ -15,6 +15,7 @@ import { DataSource, In, Repository } from 'typeorm';
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { RoutingTemplate } from '../correspondence/entities/routing-template.entity';
import { RoutingTemplateStep } from '../correspondence/entities/routing-template-step.entity';
import { ShopDrawingRevision } from '../drawing/entities/shop-drawing-revision.entity';
import { User } from '../user/entities/user.entity';
import { RfaApproveCode } from './entities/rfa-approve-code.entity';
@@ -63,6 +64,8 @@ export class RfaService {
private routingRepo: Repository<CorrespondenceRouting>,
@InjectRepository(RoutingTemplate)
private templateRepo: Repository<RoutingTemplate>,
@InjectRepository(RoutingTemplateStep)
private templateStepRepo: Repository<RoutingTemplateStep>,
private numberingService: DocumentNumberingService,
private userService: UserService,
@@ -313,14 +316,23 @@ export class RfaService {
const template = await this.templateRepo.findOne({
where: { id: templateId },
relations: ['steps'],
order: { steps: { sequence: 'ASC' } },
// relations: ['steps'], // Deprecated relation removed
});
if (!template || !template.steps || template.steps.length === 0) {
if (!template) {
throw new BadRequestException('Invalid routing template');
}
// Manual fetch of steps
const steps = await this.templateStepRepo.find({
where: { templateId: template.id },
order: { sequence: 'ASC' },
});
if (steps.length === 0) {
throw new BadRequestException('Routing template has no steps');
}
const statusForApprove = await this.rfaStatusRepo.findOne({
where: { statusCode: 'FAP' },
});
@@ -338,7 +350,7 @@ export class RfaService {
await queryRunner.manager.save(currentRevision);
// Create First Routing Step
const firstStep = template.steps[0];
const firstStep = steps[0];
const routing = queryRunner.manager.create(CorrespondenceRouting, {
correspondenceId: currentRevision.correspondenceId,
templateId: template.id,
@@ -408,16 +420,24 @@ export class RfaService {
const template = await this.templateRepo.findOne({
where: { id: currentRouting.templateId },
relations: ['steps'],
// relations: ['steps'],
});
if (!template || !template.steps)
throw new InternalServerErrorException('Template not found');
if (!template) throw new InternalServerErrorException('Template not found');
// Manual fetch steps
const steps = await this.templateStepRepo.find({
where: { templateId: template.id },
order: { sequence: 'ASC' },
});
if (steps.length === 0)
throw new InternalServerErrorException('Template steps not found');
// Call Engine to calculate next step
const result = this.workflowEngine.processAction(
currentRouting.sequence,
template.steps.length,
steps.length,
dto.action,
dto.returnToSequence
);
@@ -437,7 +457,7 @@ export class RfaService {
// Create next routing if available
if (result.nextStepSequence && dto.action !== WorkflowAction.REJECT) {
const nextStep = template.steps.find(
const nextStep = steps.find(
(s) => s.sequence === result.nextStepSequence
);
if (nextStep) {

View File

@@ -1,22 +1,36 @@
import { Entity, Column, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import {
Entity,
Column,
ManyToOne,
JoinColumn,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Transmittal } from './transmittal.entity';
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
@Entity('transmittal_items')
export class TransmittalItem {
@PrimaryColumn({ name: 'transmittal_id' })
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'transmittal_id' })
transmittalId!: number;
@PrimaryColumn({ name: 'item_type', length: 50 })
itemType!: string; // DRAWING, RFA, etc.
@Column({ name: 'item_correspondence_id' })
itemCorrespondenceId!: number;
@PrimaryColumn({ name: 'item_id' })
itemId!: number;
@Column({ default: 1 })
quantity!: number;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ nullable: true })
remarks?: string;
// Relations
@ManyToOne(() => Transmittal, (t) => t.items, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'transmittal_id' })
transmittal!: Transmittal;
@ManyToOne(() => Correspondence)
@JoinColumn({ name: 'item_correspondence_id' })
itemCorrespondence!: Correspondence;
}

View File

@@ -1,29 +1,19 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
OneToOne,
JoinColumn,
PrimaryColumn,
} from 'typeorm';
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
import { TransmittalItem } from './transmittal-item.entity';
@Entity('transmittals')
export class Transmittal {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'correspondence_id', unique: true })
@PrimaryColumn({ name: 'correspondence_id' })
correspondenceId!: number;
@Column({ name: 'transmittal_no', length: 100 })
transmittalNo!: string;
@Column({ length: 500 })
subject!: string;
@Column({
type: 'enum',
enum: ['FOR_APPROVAL', 'FOR_INFORMATION', 'FOR_REVIEW', 'OTHER'],
@@ -34,9 +24,6 @@ export class Transmittal {
@Column({ type: 'text', nullable: true })
remarks?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// Relations
@OneToOne(() => Correspondence)
@JoinColumn({ name: 'correspondence_id' })

View File

@@ -6,6 +6,7 @@ import {
Param,
UseGuards,
ParseIntPipe,
Query,
} from '@nestjs/common';
import { TransmittalService } from './transmittal.service';
import { CreateTransmittalDto } from './dto/create-transmittal.dto';
@@ -27,6 +28,13 @@ export class TransmittalController {
return this.transmittalService.create(createDto, user);
}
@Get()
@ApiOperation({ summary: 'Search Transmittals' })
findAll(@Query() searchDto: any) {
// Using any for simplicity as I can't import SearchTransmittalDto easily without checking its export
return this.transmittalService.findAll(searchDto);
}
@Get(':id')
@ApiOperation({ summary: 'Get Transmittal details' })
findOne(@Param('id', ParseIntPipe) id: number) {

View File

@@ -96,19 +96,26 @@ export class TransmittalService {
// 5. Create Transmittal
const transmittal = queryRunner.manager.create(Transmittal, {
correspondenceId: savedCorr.id,
transmittalNo: docNumber,
subject: createDto.subject,
purpose: 'FOR_REVIEW', // Default or from DTO
// remarks: createDto.remarks, // Add if in DTO
});
const savedTransmittal = await queryRunner.manager.save(transmittal);
// 6. Create Items
if (createDto.items && createDto.items.length > 0) {
// Filter only items that are effectively correspondences (or mapped as such)
// For now, assuming itemId refers to correspondenceId if itemType is CORRESPONDENCE
// If itemType is DRAWING, we skip or throw error (Schema Restriction)
const validItems = createDto.items.filter(
(i) => i.itemType === 'CORRESPONDENCE' || i.itemType === 'DRAWING' // Temporary allow DRAWING if ID matches Correspondence? Unsafe.
);
const items = createDto.items.map((item) =>
queryRunner.manager.create(TransmittalItem, {
transmittalId: savedTransmittal.id,
itemType: item.itemType,
itemId: item.itemId,
description: item.description,
transmittalId: savedCorr.id,
itemCorrespondenceId: item.itemId, // Direct mapping forced by Schema
quantity: 1, // Default, not in DTO
remarks: item.description,
})
);
await queryRunner.manager.save(items);
@@ -133,11 +140,57 @@ export class TransmittalService {
async findOne(id: number) {
const transmittal = await this.transmittalRepo.findOne({
where: { id },
relations: ['correspondence', 'items'],
where: { correspondenceId: id },
relations: ['correspondence', 'correspondence.revisions', 'items'],
});
if (!transmittal)
throw new NotFoundException(`Transmittal ID ${id} not found`);
return transmittal;
}
async findAll(query: any) {
const { page = 1, limit = 20, projectId, search } = query;
const skip = (page - 1) * limit;
const queryBuilder = this.transmittalRepo
.createQueryBuilder('transmittal')
.innerJoinAndSelect('transmittal.correspondence', 'correspondence')
.leftJoinAndSelect(
'correspondence.revisions',
'revision',
'revision.isCurrent = :isCurrent',
{ isCurrent: true }
)
.leftJoinAndSelect('transmittal.items', 'items')
.leftJoinAndSelect('items.itemCorrespondence', 'itemCorrespondence');
if (projectId) {
queryBuilder.andWhere('correspondence.projectId = :projectId', {
projectId,
});
}
if (search) {
queryBuilder.andWhere(
'(correspondence.correspondenceNumber LIKE :search OR revision.title LIKE :search)',
{ search: `%${search}%` }
);
}
const [items, total] = await queryBuilder
.orderBy('correspondence.createdAt', 'DESC')
.skip(skip)
.take(limit)
.getManyAndCount();
return {
data: items,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
}

View File

@@ -73,6 +73,24 @@ export class UserController {
return this.userService.getUserPermissions(user.user_id);
}
// --- Reference Data (Roles/Permissions) ---
@Get('roles')
@ApiOperation({ summary: 'Get all roles' })
@ApiResponse({ status: 200, description: 'List of roles' })
@RequirePermission('user.view')
findAllRoles() {
return this.userService.findAllRoles();
}
@Get('permissions')
@ApiOperation({ summary: 'Get all permissions' })
@ApiResponse({ status: 200, description: 'List of permissions' })
@RequirePermission('user.view')
findAllPermissions() {
return this.userService.findAllPermissions();
}
// --- User CRUD (Admin) ---
@Post()

View File

@@ -13,6 +13,8 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager'; // ✅ FIX: เพิ่ม 'type' ตรงนี้
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { Role } from './entities/role.entity';
import { Permission } from './entities/permission.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@@ -21,6 +23,10 @@ export class UserService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
@InjectRepository(Role)
private roleRepository: Repository<Role>,
@InjectRepository(Permission)
private permissionRepository: Repository<Permission>,
@Inject(CACHE_MANAGER) private cacheManager: Cache
) {}
@@ -64,7 +70,12 @@ export class UserService {
async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({
where: { user_id: id },
relations: ['preference', 'assignments'], // [IMPORTANT] ต้องโหลด preference มาด้วย
relations: [
'preference',
'assignments',
'assignments.role',
'assignments.role.permissions', // [FIX] Required for RBAC AbilityFactory
],
});
if (!user) {
@@ -141,6 +152,16 @@ export class UserService {
return permissionList;
}
// --- Roles & Permissions (Helper for Admin/UI) ---
async findAllRoles(): Promise<Role[]> {
return this.roleRepository.find();
}
async findAllPermissions(): Promise<Permission[]> {
return this.permissionRepository.find();
}
/**
* Helper สำหรับล้าง Cache เมื่อมีการเปลี่ยนแปลงสิทธิ์หรือบทบาท
*/

View File

@@ -87,7 +87,7 @@ export class WorkflowDslService {
if (rawState.initial) {
if (initialFound) {
throw new BadRequestException(
`DSL Error: Multiple initial states found (at "${rawState.name}").`,
`DSL Error: Multiple initial states found (at "${rawState.name}").`
);
}
compiled.initialState = rawState.name;
@@ -105,7 +105,7 @@ export class WorkflowDslService {
// Validation: Target state must exist
if (!definedStates.has(rule.to)) {
throw new BadRequestException(
`DSL Error: State "${rawState.name}" transitions via "${action}" to unknown state "${rule.to}".`,
`DSL Error: State "${rawState.name}" transitions via "${action}" to unknown state "${rule.to}".`
);
}
@@ -125,7 +125,7 @@ export class WorkflowDslService {
}
} else if (!rawState.terminal) {
this.logger.warn(
`State "${rawState.name}" is not terminal but has no transitions.`,
`State "${rawState.name}" is not terminal but has no transitions.`
);
}
@@ -147,21 +147,21 @@ export class WorkflowDslService {
compiled: CompiledWorkflow,
currentState: string,
action: string,
context: any = {},
context: any = {}
): { nextState: string; events: RawEvent[] } {
const stateConfig = compiled.states[currentState];
// 1. Validate State Existence
if (!stateConfig) {
throw new BadRequestException(
`Runtime Error: Current state "${currentState}" is invalid.`,
`Runtime Error: Current state "${currentState}" is invalid.`
);
}
// 2. Check if terminal
if (stateConfig.terminal) {
throw new BadRequestException(
`Runtime Error: Cannot transition from terminal state "${currentState}".`,
`Runtime Error: Cannot transition from terminal state "${currentState}".`
);
}
@@ -170,7 +170,7 @@ export class WorkflowDslService {
if (!transition) {
const allowed = Object.keys(stateConfig.transitions).join(', ');
throw new BadRequestException(
`Invalid Action: "${action}" is not allowed from "${currentState}". Allowed: [${allowed}]`,
`Invalid Action: "${action}" is not allowed from "${currentState}". Allowed: [${allowed}]`
);
}
@@ -182,7 +182,7 @@ export class WorkflowDslService {
const isMet = this.evaluateCondition(transition.condition, context);
if (!isMet) {
throw new BadRequestException(
'Condition Failed: The criteria for this transition are not met.',
'Condition Failed: The criteria for this transition are not met.'
);
}
}
@@ -203,24 +203,30 @@ export class WorkflowDslService {
}
if (!dsl.workflow || !dsl.states || !Array.isArray(dsl.states)) {
throw new BadRequestException(
'DSL Error: Missing required fields (workflow, states).',
'DSL Error: Missing required fields (workflow, states).'
);
}
}
private checkRequirements(
req: CompiledTransition['requirements'],
context: any,
context: any
) {
// [FIX] Early return if no requirements defined
if (!req) {
return;
}
const userRoles: string[] = context.roles || [];
const userId: string | number = context.userId;
// Check Roles (OR logic inside array)
if (req.roles.length > 0) {
const hasRole = req.roles.some((r) => userRoles.includes(r));
// Check Roles (OR logic inside array) - with null-safety
const requiredRoles = req.roles || [];
if (requiredRoles.length > 0) {
const hasRole = requiredRoles.some((r) => userRoles.includes(r));
if (!hasRole) {
throw new BadRequestException(
`Access Denied: Required roles [${req.roles.join(', ')}]`,
`Access Denied: Required roles [${requiredRoles.join(', ')}]`
);
}
}